JVM

回顾《深入理解 Java 虚拟机》之线程安全与锁优化

最后一篇

Posted by ChenJY on February 17, 2019 | Viewed times

什么叫线程安全

我之前面试的时候就被问到过这个问题,其实说几句话描述下线程安全估计谁都能做到,问题是如何下一个准确的定义呢?书中选取了 Brain Goetz 的定义:

当多线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的

通俗来讲,就是我们作为调用方无需关心多线程问题,也不需要编写额外的代码进行加锁等强制同步操作,那么这个类或者对象的方法就可以说是线程安全的。例子有 VectorConcurrentHashMap 等。

线程安全的实现方法

如何正确实现线程安全呢?JVM 提供的同步锁机制很有帮助。

不可变(Immutable)对象

众所周知,不可变(Immutable)的对象一定是线程安全的,因为只要一个不可变对象被正确地构造出来,那么在之后的运行过程中任何外部手段都无法对其进行修改,其可见状态也就永远不会改变,自然多个线程眼见的都是同样的状态。

如果是基础数据类型,那么采用 final 关键字修饰即可保证它不可变。定义不可变对象的话,要遵循以下关键点:

  1. 确保类不能被继承:将类声明为 final, 或者使用静态工厂并声明构造器为 private
  2. 使用 privatefinal 修饰符来修饰该类的属性
  3. 不要提供任何可以修改对象状态的方法(不仅仅是 set 方法, 还有任何其它可以改变状态的方法)

互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(不太准确,也可以是多个的)线程使用,而互斥是实现同步的一种手段,临界区互斥量信号量都是主要的互斥实现方式。

一句话,互斥是因,同步是果;互斥是方法,同步是目的。

synchronized 关键字

Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字如果是修饰代码块的话,经过编译之后会在同步块的前后分别生成 monitorentermonitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。

如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference,如果没有指定,就根据 synchronized 修饰的实例方法还是类方法,去取对应的对象实例或者 Class 对象来作为锁对象。

如果是修饰方法的话,方法的同步并没有通过指令 monitorentermonitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

在执行 monitorenter 指令时,首先尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,就把锁的计数器加一;相应的执行 monitorexit 时将计数器减一,当计数器为 0 时,锁就被释放。如果获取对象锁失败,那么当前线程就需要阻塞等待,直到对象锁被另一个线程释放为止。

注:synchronized 同步快对于同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步快在进入的线程执行完成之前,会阻塞后面其他线程的进入,因为 Java 线程是映射到操作系统原生线程上的,阻塞和唤醒都需要切换到内核态,消耗很多的处理器时间,所以 synchronized 是一个重量级的操作,应尽量避免,虚拟机自身对这种做了优化,例如在通知操作系统阻塞线程前加入一段自旋等待的过程,避免频繁切入到内核态中。

可重入锁 ReentrantLock

基本用法和 synchronized 相似,一个表现为 API 层面的互斥锁,使用 lock()unlock() 方法配合 try/finally 代码块来完成;另一个表现为原生语法层面的互斥锁,不过相比于 synchronizedReentrantLock 增加了一些高级功能,主要有三项:

  1. 等待可中断:当持有锁的线程长时间不释放锁的时候,等待的线程可以放弃等待转而处理其他事情
  2. 可实现公平锁:多个线程在等待锁时,必须按照申请锁的顺序来一次获得锁。synchronized 的锁是非公平的,ReentrantLock 的锁默认情况下也是非公平的。
  3. 锁可绑定多个条件:ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 synchronized 中,多个条件关联需要额外添加锁,ReentrantLock 无需这样做,只需要多次调用 new Condition() 即可

两种互斥同步方法的性能对比

多线程环境下,synchronized 的吞吐量下降严重,而 ReentrantLock 基本保持在同一个比较稳定的水平上,JDK1.6 之后,synchronizedReentrantLock 性能基本上完成持平了,建议使用 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 虚拟机》 周志明著

许可协议


这是一个不定时更新的、披着程序员外衣的文青小号。

在这里,既分享极客技术,也记录人间烟火,欢迎关注。


Comment