《Java并发编程实战》 线程安全之可见性
words: 4.2k views: time: 15min要编写正确的并发程序,关键在于:在访问共享的可变状态时进行正确的管理。同步的另一个重要目的是内存可见性,我们不仅希望防止某个线程正在使用的对象状态被另一个线程同时修改,而且希望当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
.后面介绍Jvm内存模型时会提到java线程之间内存可见性的必要条件:满足Happens-Before规则。
1. 可见性问题
1 | public class Demo { |
示例中主线程与读线程都访问共享变量ready
和number
。主线程启动读线程,然后将number
设置为42,并将ready
设为true
。读线程一直循环直到发现ready
的值变为true
,然后输出number
并退出。但是由于没有同步机制,将无法保证主线程写入的ready
值和number
对于读线程是可见的,虽然看起来会输出42,但很可能输出0或者根本无法终止。
因为在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
.由于cpu的处理速度与内存的读写速度相差太大,一般会在cpu与内存之间加一层高速缓存,高速缓存本身还会分为三级缓存,在高速缓存与cpu之间还有一层寄存器,即cpu直接面对的存储是寄存器。在多核的情况下,各个cpu使用的寄存器以及至少L1级缓存之间是不能相互访问的,所以线程可能看不到运行在其他核上的线程的一些过程修改。
.另外在操作系统调度的时候,会临时保存当前执行线程的上下文,然后切换到另外一个线程的上下文,有些过程修改可能就地存储在寄存器或缓存中,所以即使两个线程共用的是同一个进程的内存空间,当发生上下文切换时,另一个线程也无法看到保存在高速缓存或寄存器中的修改。
.而同步会强制处理器把当前线程所做的修改同步到内存中。此外计算机为了执行效率,对处理器进行了非常精密的设计,它会对输入的指令进行优化排序甚至多指令同时执行,只要保证最后的结果与输入的指令效果一致即可,所以你无法根据输入的指令序列推断指令执行的顺序以及结束之前任一时刻的执行结果,同样的当一个线程观察另外一个线程时,它也无法知道另一线程的执行顺序或者任一时候正在做什么,而同步可以让一个线程可以看到另一个线程某一阶段的执行结果。
1.1. 锁同步
同步可以确保某个线程以一种可预测的方式来查看另一个线程的执行结果,当线程A执行完某个同步代码块,线程B随后进入由同一个锁保护的同步代码块,这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。这也是为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程是可见的。
1.2. volatile变量
volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此读取volatile 变量时总会返回最新写入的值。而且当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,不会将该变量上的操作与其他内存操作一起重排序。但是volatile不具备原子性的语义,使用时需要注意,例如递增操作++count
,除非你能确保只有一个线程对变量执行写操作。
一般正确地使用volatile变量需要满足以下条件:
- 对变量的写入操作不依赖变量的当前值(读-改-写),或者能确保只有单个线程进行更新;
- 变量不会与其他状态变量一起纳入不变性条件中;
- 访问变量时不需要加锁;
2. 非原子的64位变量操作
上面示例中,当读线程查看ready时,可能会得到一个已经失效的值,除非在每次访问时都使用同步。当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值。这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。
java内存模型要求,变量的读取和写入都必须是原子操作,但对于非volatile类型的long和double变量,jvm允许将64位的读操作或写操作分解为两个32位的操作,就是说即使不考虑失效数据的问题,在多线程程序中使用共享可变的long和double等类型变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
.目前intel平台的x64 hostpot jvm中,long、double的访问都是原子的。long在x86的jre上是非原子的,会出现写高低位的问题。double因为一般cpu都有专门的浮点单元,其存取哪怕是在32bit jvm上一般也都是原子的。
3. 线程封闭
swing
的可视化组件和数据模型对象都不是线程安全的,但swing
通过将他们封闭到事件分发线程中来实现线程安全性。
JDBC
规范并不要求Connection
对象是线程安全的,但在典型的服务器应用程序中,大多数请求如Servlet
或EJB
调用等都是由单个线程来处理的,线程从连接池中获得一个Connection对象,然后用该对象来处理请求,使用完后返回连接池,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,所以整个过程中Connection对象始终只由一个线程拥有。
它们都基于一个想法,即如果数据仅在单个线程内访问,就不需要同步,称为线程封闭,它是实现线程安全最简单的方式之一。
3.1. Ad-hoc线程封闭
指维护线程封闭性的职责完全由程序实现来承担,因为没有任何一种语言特性,能将对象封闭到目标线程上。
在volatile
变量上存在一种特殊的线程封闭,只要你能确保只有单个线程对共享的volatile变量执行写入操作,就可以安全地在这些共享的volatile变量上执行(读-改-写),这相当于将修改操作封闭在单个线程中,且volatile语义保证了其他线程能看到最新的值。
3.2. 栈封闭
即局部变量,其本身就是封闭在线程之中。
3.3. ThreadLocal
ThreadLocal
可以使线程中的某个值与保存值的对象关联起来,其提供了get
与set
接口,get
总是返回由当前执行线程在调用set
时设置的最新值。这些方法为每个使用该变量的线程都存有一份独立的副本。
.ThreadLocal
的源码分析,可以参考:https://shanhm1991.github.io/2018/04/03/20180403/ ,其思路是让每个Thread
都持有一个私有的ThreadLocalMap
,然后使用共享的key来保存值,而这个key就是共享的ThreadLocal实例,因此每个ThreadLocal也就对应一个本地变量。但是,如果这个本地变量本身就是一个线程共享的对象,那么就算使用ThreadLocal也不是线程安全的。
ThreadLocal对象一般用于防止对可变的单实例变量或全局变量进行共享。例如,通过将JDBC的连接保存到ThreadLocal对象中,可以让每个线程都拥有属于自己的连接,且省去了调用参数的传递,连接对象会一直伴随线程执行的整个过程直到 remove
1 | private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){ |
当某个线程初次调用ThreadLocal.get()
方法时,会调用init()
来获取初始值。可以将ThreadLocal<T>
视为包含了Map<Thread,T>
对象,其中保存了特定于该线程的值。但ThreadLocal
并非如此,这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
4. this逸出问题
发布对象最简单的方法是将对象的引用保存到一个公有的静态变量中,但是当发布某个对象时,可能会间接的发布其他对象。
1 | public class ThisEscape{ |
示例中,当内部EventListener
实例发布时,在外部封装的ThisEscape
实例也逸出了,因为在内部类的实例中包含了对ThisEscape
实例的隐含引用this
。当且仅当对象的构造函数返回时,对象才处于可预测的状态。因此当从对象的构造函数中发布对象时,只是发布了一个尚未完成的对象,即使发布对象的语句位于构造函数的最后一行也是如此。
构造过程中this
逸出的一个常见错误是:在构造函数中启动一个线程,这会导致this
引用被新创建的线程共享,即在对象尚未完全构造之前,新的线程就可以看见它。另外如果在构造函数中调用一个可改写的实例方法时(即不是private也不是final方法),同样也会导致this在构造过程中逸出。
1 | public class SafeListener{ |
如果确实想在构造函数注册一个事件监听器或启动线程,可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。其实在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过start
或者initialize
方法来启动。
5. final域
在java内存模型中,final域有着特殊的语义,final域能确保初始化过程的安全性,因此可以不受限制的访问不可变对象,并在共享这些对象时无须同步,因为不可变对象一定是线程安全的。
在“不可变的对象”与“不可变的对象引用”之间存在着差异。保存在不可变对象中的对象状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。因此在访问和更新多个相关变量出现的竞态条件时,可以将这些变量全部保存在一个不可变的对象中,然后直接以替换不可变对象的方式来消除竞争。另外,“虽然fianl类型的域是不能修改的,但如果final域所引用的对象是可变的,那么这些被引用的对象照样是可以修改的。
1 | //执行因式分解,并缓存结果。收到请求后通过判断请求的参数是否等于缓存的参数来决定是否直接读取缓存的结果 |
如果是一个可变的变量则必须使用锁来确保原子性,而如果是一个不可变对象,那么当线程获得了该对象的引用后,就不必担心另一个线程会修改对象的状态。
如果要更新这些变量,那么可以创建一个新的容器对象,其他使用原有对象的线程仍然会看到对象处于一致的状态。再将这个对象设置成volatile,那么当一个线程更新了对象状态时,其他线程就会立即看到新缓存的数据。比如示例中,利用final以及volatie,在没有显示地使用锁的情况下仍然保证对象是线程安全的。
6. 安全发布
即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。可变对象必须通过安全的方式来发布,因为对象的初始化无法得到保证,除了发布对象的线程外,其他线程都可能看到尚未完全创建的对象以及对象包含域的失效值。并且在发布和使用该对象时都必须使用同步来确保对象状态呈现一致性,因为如果对象在构造后可以修改,那么安全发布只能确保发布当时的状态可见性。
事实不可变对象,即对象在发布后不会被修改,那么对于在没有额外同步的情况下访问这些对象的线程来说,只要保证对象是被安全发布的就足够了。所有的安全发布机制都能确保,当对象的引用对所有访问的线程可见时,其状态对所有线程也是可见的,且如果对象的状态不再改变,那就足以保证所有的访问都是安全的。
不可变对象,即状态不可修改,所有域都是final类型,则不需要使用同步仍然可以安全,但是,如果final类型的域所指向的是可变对象,
那么在访问这些域所指向的对象的状态时仍然需要同步。
一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用;
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中;
- 将对象的引用保存某个正确构造的对象的final类型域中;
- 将对象的引用保存到一个由锁保护的域中;
方式1通常是最简单安全的方式,即使用静态的初始化器发布一个静态对象,因为静态初始化器由jvm在类的初始化阶段执行,由于jvm内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布,比如:
1 | public static Holeder holder = new Holder(4); |
方式4的做法通常是将对象放入一个线程安全的容器中,如果线程T1将对象放入一个线程安全的容器,随后线程T2获取这个对象,则不再需要额外的同步。
参考:
- Copyright ©《java并发编程实战》