什么叫线程安全
我之前面试的时候就被问到过这个问题,其实说几句话描述下线程安全估计谁都能做到,问题是如何下一个准确的定义
呢?书中选取了 Brain Goetz
的定义:
当多线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的
通俗来讲,就是我们作为调用方无需关心多线程问题,也不需要编写额外的代码进行加锁等强制同步操作,那么这个类或者对象的方法就可以说是线程安全的。例子有 Vector
、ConcurrentHashMap
等。
线程安全的实现方法
如何正确实现线程安全呢?JVM
提供的同步
和锁机制
很有帮助。
不可变(Immutable)对象
众所周知,不可变(Immutable
)的对象一定是线程安全的,因为只要一个不可变对象被正确地构造出来,那么在之后的运行过程中任何外部手段都无法对其进行修改,其可见状态也就永远不会改变,自然多个线程眼见的都是同样的状态。
如果是基础数据类型,那么采用 final
关键字修饰即可保证它不可变。定义不可变对象的话,要遵循以下关键点:
- 确保类不能被继承:将类声明为
final
, 或者使用静态工厂并声明构造器为private
。 - 使用
private
和final
修饰符来修饰该类的属性 - 不要提供任何可以修改对象状态的方法(不仅仅是
set
方法, 还有任何其它可以改变状态的方法)
互斥同步
同步
是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(不太准确,也可以是多个的)线程使用,而互斥
是实现同步的一种手段,临界区
、互斥量
、信号量
都是主要的互斥实现方式。
一句话,互斥是因,同步是果;互斥是方法,同步是目的。
synchronized 关键字
在 Java
中,最基本的互斥同步手段就是 synchronized
关键字,synchronized
关键字如果是修饰代码块的话,经过编译之后会在同步块的前后分别生成 monitorenter
和 monitorexit
这两个字节码指令,这两个字节码都需要一个 reference
类型的参数来指明要锁定和解锁的对象。
如果 Java
程序中的 synchronized
明确指定了对象参数,那就是这个对象的 reference
,如果没有指定,就根据 synchronized
修饰的实例方法还是类方法,去取对应的对象实例或者 Class
对象来作为锁对象。
如果是修饰方法的话,方法的同步并没有通过指令 monitorenter
和 monitorexit
来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED
标示符。JVM
就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先获取 monitor
,获取成功之后才能执行方法体,方法执行完后再释放 monitor
。在方法执行期间,其他任何线程都无法再获得同一个 monitor
对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
在执行 monitorenter
指令时,首先尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,就把锁的计数器加一;相应的执行 monitorexit
时将计数器减一,当计数器为 0
时,锁就被释放。如果获取对象锁失败,那么当前线程就需要阻塞等待,直到对象锁被另一个线程释放为止。
注:
synchronized
同步快对于同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步快在进入的线程执行完成之前,会阻塞后面其他线程的进入,因为Java
线程是映射到操作系统原生线程上的,阻塞和唤醒都需要切换到内核态,消耗很多的处理器时间,所以synchronized
是一个重量级的操作,应尽量避免,虚拟机自身对这种做了优化,例如在通知操作系统阻塞线程前加入一段自旋等待的过程,避免频繁切入到内核态中。
可重入锁 ReentrantLock
基本用法和 synchronized
相似,一个表现为 API
层面的互斥锁,使用 lock()
和 unlock()
方法配合 try/finally
代码块来完成;另一个表现为原生语法层面的互斥锁,不过相比于 synchronized
,ReentrantLock
增加了一些高级功能,主要有三项:
- 等待可中断:当持有锁的线程长时间不释放锁的时候,等待的线程可以放弃等待转而处理其他事情
- 可实现公平锁:多个线程在等待锁时,必须按照申请锁的顺序来一次获得锁。
synchronized
的锁是非公平的,ReentrantLock
的锁默认情况下也是非公平的。 - 锁可绑定多个条件:
ReentrantLock
对象可以同时绑定多个Condition
对象,而在synchronized
中,多个条件关联需要额外添加锁,ReentrantLock
无需这样做,只需要多次调用new Condition()
即可
两种互斥同步方法的性能对比
多线程环境下,synchronized
的吞吐量下降严重,而 ReentrantLock
基本保持在同一个比较稳定的水平上,JDK1.6
之后,synchronized
和 ReentrantLock
性能基本上完成持平了,建议使用 synchronized
来进行同步。
非阻塞同步
互斥同步
最主要的问题是在于进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为阻塞同步
。
从处理方式来说,互斥同步
属于一种悲观
的并发策略。现在我们还有一种基于冲突检测的乐观并发策略
,就是先进行操作,如果没有其他线程争用共享数据,那操作就成了;如果有共享数据争用就产生了冲突,再采取其他补偿措施(最常见的就是不断重试直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步
。
我们需要操作和冲突检测具备原子性
,所以需要硬件的帮助。JDK1.5
之后,Java
程序中才可以使用 CAS
操作,它由 sun.misc.Unsafe
类里的 compareAndSwapInt()
和 compareAndSwapLong()
等几个方法包提供,JVM
内部对这些方法做了特殊处理,编译出来的结果就是一条平台相关的处理器 CAS
指令。
CAS
有三个参数,分别是内存地址 V
,旧预期值 A
与新值 B
,当且仅当 V
符合旧预期值 A
时,用 B
更新 V
。
因为 Unsafe 包不是提供给用户程序调用的类,且限制了仅有 Bootstrap ClassLoader
加载的类才能访问它,因此如果不采取反射手段,我们只能通过间接 API
来使用,例如原子类 AtomicInteger
。内部就是循环采用 CAS
更新值。
自旋锁与自适应锁
我们说了,互斥同步的最大问题是挂起线程和恢复线程都需要转入内核态,代价太大。而鉴于一个观察到的事实:“共享数据的锁定状态一般持续时间很短”,因此为了这么短的时候去做一次上下文切换不太值得,反而可以让那个线程等一下,暂时不放弃 CPU
的时间片,看看持有锁的线程是不是会很快释放锁,这就是自旋锁,在 JDK1.6
默认开启。
当然自旋锁不是万能的,它会占据处理器时间片,如果等待的锁立刻被释放了那还好,否则就会陷入空转导致资源浪费,因此默认的自旋次数是 10
次。JDK1.6
还引入了自适应的自旋锁,意思是自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定(就是个优化,看看之前是不是很快拿到了锁,是这次就等稍微长一点;否说明很难拿到嘛,等待短一点不行就算了)
锁消除
对于一些要求同步,但是检测到不可能存在共享数据竞争的锁进行消除,主要依赖于逃逸分析的数据支持实现这个功能
锁粗化
如果一系列连续操作对同一个对象反复加锁解锁,例如加锁操作出现在循环体中,那么这会导致不必要的性能损耗。如果虚拟机探测到这种情况,将会把锁的范围扩大到整个操作序列的外部,这样只需要加一次锁就够了。
轻量级锁
轻量级锁是针对通过操作系统互斥量实现的传统锁而言的,后者称为 “重量级锁”。
HotSpot
虚拟机的对象头(Object Header)
分为两部分信息,第一部分存储对象自身的运行数据(哈希码
、GC 分代年龄
等),这部分数据长度在 32bit
或者 64bit
(取决于虚拟机类型),官方称其为 Mark World
。它是实现轻量级锁的关键;另一部分则用来存储指向方法区对象类型数据的指针,如果对象是数组
的话,还会有一个额外的部分来存储数组长度
。
在代码进入同步块时,如果此同步对象没有被锁定(即锁标志位为 01
状态),虚拟机首先将在当前线程的栈帧中新建一个名为锁记录(Lock Record)
的空间,存储对象的 Mark World
拷贝,然后 JVM
通过 CAS
操作尝试将对象的 Mark World
更新为指向锁记录空间的指针,如果更新成功则该线程拥有了该对象的锁,且对象的 Mark World
锁标志位变成 00
意为轻量级锁定状态。如果 CAS
更新失败要么该线程之前获取过了,直接进入同步块;要么是其他线程抢占了。解锁操作也是通过 CAS
将栈帧中的 Mark World
拷贝替换回对象的 Mark World
。
参考资料
- 《深入理解 Java 虚拟机》 周志明著
许可协议
- 本文遵守创作共享 CC BY-NC-SA 3.0协议