可见性

上篇文章中讲到synchronized可实现原子性,它还有另一个特性:内存可见性。我们不仅希望防止当某个线程正在访问对象的状态而另一个线程正在同时修改该状态,也希望一个线程修改了对象的状态后其他线程能够及时看到变化。如果没有同步,那么就无法实现这种情况。
来看一段简单的代码:

  1. @NotThreadSafe
  2. public class NoVisibility {
  3. private int number;
  4. public void setNumber(int number) {
  5. this.number = number;
  6. }
  7. public int getNumber() {
  8. return number;
  9. }
  10. public static void main(String[] args) {
  11. NoVisibility noVisibility = new NoVisibility();
  12. Thread thread1 = new Thread(() -> {
  13. noVisibility.setNumber(1);
  14. });
  15. Thread thread2 = new Thread(() -> {
  16. int number = noVisibility.getNumber();
  17. System.out.println(number);
  18. });
  19. thread1.start();
  20. thread2.start();
  21. }
  22. }
  23. 输出结果:可能是1,也可能是0

虽然看起来结果会输出1,但事实上可能是1,也可能是0,原因是代码中没有使用足够的同步机制,因此无法保证线程1设置的值对线程2来说是可见的。
在没有同步的情况下,编译器、处理器以及运行时都可能会对操作的执行顺序进行调整,也就是指令重排序。有一种简单的方法能避免这些问题:只要在多个线程之间有共享数据,就使用正确的同步。

加锁与可见性

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如下图所示,当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证A看到的变量值在B获得锁后同样可以由B看到。如果没有同步,那么就无法实现刚才所说的效果。
未命名文件.jpg
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读写操作的线程必须使用同一把锁。

Volatile变量

java提供了一种比synchronized关键字更轻量级的同步机制,即volatile变量,它用来确保某个线程将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器和运行时都会注意到这个变量时共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方,因此在读取volatile类型的变量时总是会返回最新的值。
虽然volatile变量很方便,但也存在局限性,它只能保证可见性,使用时一定要非常小心,当满足以下所有条件时才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者确保单线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁

    线程封闭

    当访问共享的可变数据时通常需要使用同步,一种避免使用同步的方式就是不共享数据,如果仅在单线程内访问数据就不需要同步,这种技术被称为线程封闭,它是实现线程安全性的最简单的方式之一。

    ThreadLocal类

    维持线程封闭性的一种规范方法就是使用ThreadLocal类,这个类能使线程中的某个值与保存值的对象关联起来,听起来很像Map数据结构。ThreadLocal有以下几个方法:

  • ThreadLocal.get: 获取ThreadLocal中当前线程共享变量的值。

  • ThreadLocal.set: 设置ThreadLocal中当前线程共享变量的值。
  • ThreadLocal.remove: 移除ThreadLocal中当前线程共享变量的值。
  • ThreadLocal.initialValue: ThreadLocal没有被当前线程赋值时或当前线程刚调用remove方法后调用get方法,返回此方法值。
    1. public T get() {
    2. Thread t = Thread.currentThread();
    3. ThreadLocalMap map = getMap(t);
    4. if (map != null) {
    5. ThreadLocalMap.Entry e = map.getEntry(this);
    6. if (e != null) {
    7. @SuppressWarnings("unchecked")
    8. T result = (T)e.value;
    9. return result;
    10. }
    11. }
    12. return setInitialValue();
    13. }
    上述代码是JDK -> ThreadLocal类的get()方法源码,调用该方法时实际上是根据当前线程来获取ThreadLocalMap对象,然后根据当前ThreadLocal来获取当前线程共享变量T,而其他线程不会对此共享变量造成影响,所以它能实现线程安全。ThreadLocal.set(),ThreadLocal.remove()也是同样的道理。

关于ThreadLocalMap弱引用问题:
当线程没有结束,但是ThreadLocal已经被回收,则可能导致线程中存在ThreadLocalMap的键值对,造成内存泄露。(ThreadLocal被回收,ThreadLocal关联的线程共享变量还存在)。
虽然ThreadLocal的get,set方法可以清除ThreadLocalMap中key为null的value,但是get,set方法在内存泄露后并不会必然调用,所以为了防止此类情况的出现,我们有两种手段。

  • 使用完线程共享变量后,显示调用ThreadLocalMap.remove方法清除线程共享变量;
  • JDK建议ThreadLocal定义为private static,这样ThreadLocal的弱引用问题则不存在了。

不变性

之前我们介绍了原子性和可见性的相关问题,但是我们都是在多线程情况下访问可变对象,那么假如对象是不可变的呢?满足同步需求的另一种方法是使用不可变对象,不可变对象一定是线程安全的。但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都i是final类型的,这个对象仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的

    Final域

    final用于构造不可变对象,在java内存模型中,它能确保初始化过程中的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。
    除非需要更高的可见性或者需要某个域是可变的,否则应将所有的域都声明为私有域或者final域。

    总结

    在并发程序中使用和共享对象时,可以使用以下策略:

  • 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改

  • 只读共享:在没有额外同比的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读变量包括不可变对象和事实不可变对象。
  • 线程安全共享:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步
  • 保护对象:被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象