面试题八:如何写出线程安全的程序?

如何写出线程安全的程序?

一、面试官视角:这道题想考察什么?

  • 是否对线程安全有初步了解
  • 是否对线程安全的产生原因有思考
  • 是否知道final,volatile关键字的作用
  • 是否清楚JDK 1.5之前Java DCL为什么有缺陷
  • 是否清楚的知道如何编写线程安全的程序
  • 是否对ThreadLocal的使用注意事项有认识

二、题目剖析:

1、什么是线程安全问题?

场景:

假如有三个线程在访问CPU和内存,它们分别为:Thread1、Thread2和Thread3,每一个线程都有一个自己的内存副本(工作内存1、工作内存2和工作内存3),内存副本其实就是Java的内存模型,此时这三个线程同时触发 ++ 运算,比如Thread1先去它自己对应的工作内存1中进行 ++ 运算,比如从5++到了6,此时主内存的数值还是5,其它两个线程对应的内存副本的数值也都还是5,那么问题来了,数值还没来得及同步呢,这个时候Thread2也对自己对应的工作内存2进行 ++ 运算,从5++到了6,这个时候可能是时间片的原因或其它原因,Thread1才把它的工作内存1的数据同步到主内存中,此时主内存的数值变成了6,接下来主内存发了一个指令,要把它自己的数值刷新到其它的内存副本中,这个时候Thread1、Thread2和Thread3对应的工作内存3的数值都变成了6,那么问题又来了,Thread2它自己已经完成了一次 ++ 运算,它本身已经从5++到了6了,按理来说Thread1和Thread2都已经进行 ++ 运算了,主内存的数值应该是7,但现在的结果却还是6,这是不是就出现了不一致的情况,再比如Thread3也进行了 ++ 运算,从6++到了7,这结果也不对,应该是8,因为这三个线程都进行了 ++ 运算,应该是从5+3=8,这就是线程安全问题。

结论:线程安全问题本质上就是可变资源(内存)线程间共享的问题;不可变、不共享是没有线程安全问题的。

扩展:进程有安全问题吗?

没有;因为进程之间只共享CPU时间片(抢时间片),进程之间的内存是相互独立的,一旦进程被kill了,其所拥有的内存都会还给物理内存,但线程是存在于进程当中的,同一个进程中的线程是可以共享内存资源的。

2、如何实现线程安全?

  • 不共享资源

    • 可重入函数

      1
      2
      3
      4
      5
      // 可重入函数
      // 什么是可重入函数:传入一个参数进来,经过一系列的运算,再返回一个值出去,中间不会涉及到任何的外部内存的访问、修改,这就叫可重入函数,它没有副作用。
      public static int addTwo(int num) {
      return num + 2;
      }
    • 使用ThreadLocal

      上面的 ++ 运算的例子可以看出来,虽然在每一个线程当中都会去访问这个值(数值5),但最终访问的是自己的线程当中的内存副本里面的数值5。

      比如下面的例子:

      这个token的使用场景就是,一个服务器提供很多服务,每一个用户请求进来,都会开一个线程为他提供服务,所以每一个用户属于不同的线程,那么每一个线程去访问这个token,都是不一样的,都有一个属于自己的String类型的副本token,这样的话就不会互相干扰,这样的好处就是,因为不共享资源,所以没有线程安全的问题。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      final static ThreadLocal<String> token = new ThreadLocal<>();
      public static void threadLocal() {
      Runnable runnable = () -> {
      token.set(UUID.randomUUID().toString());
      ...
      System.out.println("e." + Thread.currentThread().getName()+":"+token.get());
      };

      startThread(5, runnable);
      }

      深入:ThreadLocal剖析(ThreadLocal.java):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      public void set(T value) {
      Thread t = Thread.currentThread();
      ThreadLocalMap map = getMap(t);
      if(map != null) {
      // key:this,就是ThreadLocal
      // value:你想要传入的值
      map.set(this, value);
      }else {
      createMap(t, value);
      }
      }

      // ThreadLocalMap存在线程上的
      ThreadLocalMap getMap(Thread t) {
      return t.threadLocal;
      }

      // 结论:ThreadLocal本质上是一个绑定在了线程上的一个ThreadLocalMap;如果我和你的线程不一样,那么值一定是不一样的。

      ThreadLoalMap 与 WeakHashMap:

      ThreadLocalMap WeakHashMap
      对象持有 弱引用 弱引用
      对象GC回收 不影响 不影响
      引用清除策略 1、主动移除
      2、线程退出时移除
      1、主动移除
      2、GC 后移除(ReferenceQueue)
      Hash冲突 开放定址法 单链表法
      Hash计算 神奇数字的倍数 对象hashCode再散列
      适用场景 对象较少 通用

      ThreadLocal的使用建议:

      • 声明为全局静态final成员

        ThreadLocal在一个线程当中有一个实例就够了,没必要每次创建的时候都去弄一个出来,我们知道使用ThreadLocalMap对象set()的时候,它是以ThreadLocal为key的,所以如果不断的去变化ThreadLocal的对象引用的话,那些对象set()进去就永远找不到了;如果不声明为全局静态final成员的话,还有另外一个问题就是,可见性问题。

      • 避免存储大量对象

        这是由底层的数据结构决定的

      • 用完后及时移除对象

        因为ThreadLocal本身没有监听机制

  • 共享不可变资源

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class FinalFieldExample {
    final int x;
    int y;

    public FinalFieldExample() {
    x = 3;
    y = 4;
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    static FinalFieldExample f;
    static void writer() {
    f = new FinalFieldExample();
    }

    static void reader() {
    if(f != null) {
    int i = f.x;
    int j = f.y;
    }
    }

    // writer 和 reader是两个线程
    // 我们所期望的i 和 j的值是:i=3,j=4
    // 但实际情况,有可能是i=3,j=0,这个取决于虚拟机的实现或者cpu的架构的特征,从而来决定指令会不会重排序,如果重排序的话,首先会将非final的成员,重排序到构造方法之外,如上面的示例,因为 x 是final的,所以 x 一定会在构造方法之内被赋值,但 y 是非final的,有可能构造方法执行完了,y的赋值操作还没有弄完,但在reader线程中,发现 f 已经不为null了,可以读值了,所以有可能 i=3,j=0的情况存在。
    // 结论:被final修饰的变量、方法以及类,不仅不能被修改、不能被覆写、不能被继承,而且还禁止重排序。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Singleton {
    private volatile static Singleton singleton;
    private Singleton() {}
    public static Singleton getInstance() {
    // 判断是否为null
    if(singleton == null) {
    // 是null,加锁
    synchronized(Singleton.class) {
    // 再判断是否为null
    if(singleton == null) {
    // 还为null,开始初始化,先调用构造方法,再赋值给singleton
    singleton = new Singleton();
    }
    }
    }
    return singleton;
    }
    }

    // jdk1.5之后,volatile语义被增强了,除了保证线程间可见性,还有禁止重排序功能,所以写DCL(单例)的时候,把volatile加上。
    // 上面的示例如果不加volatile的话,有可能出现类似于重排序的问题,可能在锁里面就把singleton给赋值了,但是构造方法还没执行完,那其它线程有可能就会拿到一个没有被初始化完的singleton的引用,这样就会引发问题。
  • 共享可变资源

    • 保证可见性(你改了,我看得到)

      • 使用final关键字

      • 使用volatile关键字

      • 加锁,锁释放时会强制将缓存刷新到主内存

        加锁只是对另外跟你同样争用同一个锁的那些线程才能保证可见性,而且加锁的话,会在锁释放时强制将缓存刷新到主内存当中,这就是为什么其它线程加锁了才能看到我刷新到主内存的这个值,原因就在于,它只有加锁了,它才会去从主内存当中刷新,不然的话,它有可能就拿着自己的内存副本去读了(上面的 ++ 运算示例就是因为没有加锁、没有保证可见性也没有保证操作原子性)

    • 保证操作原子性(你在改,我就不能改,我在改,你就不能改;反例:上面的 ++ 运算,你改我也改)

      • 加锁,保证操作的互斥性

      • 使用CAS指令(如Unsafe.compareAndSwapInt,Unsafe不是公开的,需要用反射才能获取到)

      • 使用原子数值类型(如AtomicInteger)

      • 使用原子属性更新器(AtomicRerenceFieldUpdater)

    • 禁止重排序(加不加final,那执行顺序就是不一样)

      • 使用final关键字
      • 使用volatile关键字