Skip to main content

线程安全与锁优化

info

《深入理解Java虚拟机》第13章读书笔记

线程安全

关于线程安全的定义

多个线程访问同一个对象时,不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用该对象都可以获得正确的结果,称该对象为线程安全的--Brian Goetz,《Java并发编程实战》

线程安全不是二元状态,按照程度分为:

  • 不可变

常见的不可变类有String、Integer等包装类,通过给底层基本类型数据加上final修饰符保证(比如String对应一个final char数组)

  • 绝对线程安全

不需要额外操作,每次访问对象结果都正确。以Vector类为例,尽管每个操作方法都加上了synchronized关键字,但是还需是要在调用端做同步限制

  • 相对线程安全

不需要额外操作,单次访问对象结果正确。但是对于特定访问顺序还是需要额外的同步手段。在Java中的Vector、Hashtable、以及Collections.synchronizedCollection()包装的集合类都是相对线程安全的。

  • 线程对立

完全不是线程安全的,无论调用端是否采用同步措施都不能保证结果正确性。

实现线程安全

互斥同步

互斥是手段,同步是目标,前者是因,后者是果。最常见的是synchronized关键字,编译后生成monitorenter和monitorexit两条JVM指令,指令操作数是加锁和解锁的reference类型对象

  1. synchronized修饰代码块,括号后面对象的引用作为reference
  2. synchronized修饰方法体,后面跟了括号指定对象同上,没有跟括号
  • 修饰实例方法以方法所属对象实例为锁
  • 修饰静态方法以方法所属类Class对象为锁

由于JVM线程1:1映射到操作系统线程,JVM线程阻塞、唤醒由操作系统完成,这就涉及到内核态和用户态之间的转换。在未优化前,此时的synchronized加锁、解锁成本很高。另一种实现方式是可重入锁(ReentrantLock),和synchronized的区别:

  1. 等待可中断
  2. 可以选择是否公平;默认是非公平锁,通过构造函数参数指定
  3. 可以绑定多个条件

非阻塞同步

互斥同步主要问题是带来阻塞和唤醒线程的开销,因此也称阻塞同步。本质是一种悲观锁,无论共享资源是否真的会出现竞争都会加锁,这也导致了从用户态到内核态、维护锁计数器、检查需要唤醒线程等开销。另一种非阻塞同步是基于冲突检测的乐观锁,它先进行资源操作,发现有其他线程竞争再进行补偿操作,常用就是不断重试

乐观锁要求资源操作和冲突检测必须是原子性的,此时只能依靠硬件资源提供的原子指令完成,常用的就是CAS(Compare And Swap)。一条CAS指令包含3个操作:V(变量地址)、A(旧值)、B(新值),当变量V值为A时会更新为B,否则不更新。sun.misc.Unsafe包装compareAndSwapInt()和compareAndSwapLong(),它们编译之后是一条CAS指令。但是Unsafe类不能使用(Unsafe::getUnsafe()代码中限制只有Bootstrap ClassLoader加载的类才能用它),JDK再往上封装一层,提供了原子类(AtomicInteger、AtomicLong等)使用

ABA问题

一个变量初始值为A,被另一个变量改为B后又改为A,CAS操作任务A没有改变过,这个逻辑漏洞称为CAS的ABA问题,JUC中AtomicStampedReference类通过版本变量值来解决这个问题

无同步

同步和线程安全没有必要联系,代码不涉及共享数据就不需要采取同步措施。这样天然线程安全的代码有两类:

  • 可重入代码:输入参数不变,返回结果也不变
  • 线程本地存储(Thread Local Storage):Java中就是java.lang.ThreadLocal类

锁优化

自旋锁与自适应自旋

在互斥同步时,等待资源的线程不阻塞,而是让其空转自旋,当持有锁线程很快就释放锁,该线程就不用再经历挂起和恢复了。自旋锁(-XX:+UseSpining)自JDK6默认开启,但是也不能长时间空转浪费CPU资源,通过-XX:PreBlockSpin=n指定自旋次数,默认为10

自适应自旋:自旋时间根据线程前一次同一个锁上的自旋时间和锁拥有线程状态而变化。线程自旋刚获得过锁,那个认为这次自旋很可能又获得锁,加长自旋时间,反之自旋很少获得锁不自旋

锁消除

举个例子:

public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

StringBuffer的append方法加了synchronized关键字,但是变量sb未出现逃逸,加锁没有必要,经过服务端编译器的即时编译后会被忽略

锁粗化

编写代码时要求同步块越小越好,让同步操作尽快结束释放锁,但如果是连续对同一个对象加锁解锁,反而导致不必要的性能损失。仍以上述concat方法为例,2个append方法连续调用,锁粗化就是把加锁返回扩大到两个append方法,只需要加解锁一次即可

轻量级锁

轻量级锁是JDK6新加入的锁机制,“轻量”是相对于使用操作系统传统互斥量的锁而言。在介绍轻量级锁之前,先要回顾下对象头(Mark Word)内存布局:

待补充
为什么可以用30个bit存32位的指针地址?

Java内存4字节对齐,最低2位一定是00,因此可以用30个bit

轻量级锁加锁过程:当前标志位状态为01(未锁定),虚拟机在当前线程栈帧创建一个锁记录空间(Lock Record),并把对象头拷贝到此;接着使用CAS操作尝试更新对象头中的指针,若更新成功则将标志位改为00,表示轻量级锁定,若更新失败,至少有两条线程争用该资源,此时再检查对象头中的线程指针是否指向当前线程,是的话直接进入同步块,否则锁升级为重量级锁,标志位置为10,对象头指针改为指向重量级锁

轻量级锁解锁过程:使用CAS操作把栈中的Displaced Mark Word还原到对象头中,替换失败说明有其他线程获取该锁,在释放锁时唤醒被挂起的线程

轻量级锁在没有锁竞争时,通过CAS操作避免互斥量开销,但是有锁竞争时轻量级锁反而会增加开销

偏向锁

轻量级锁在无竞争情况下使用CAS消除互斥量使用,而偏向锁更加极端,连CAS操作都取消。偏向锁偏向于第一次获取到它的线程,认为没有其他线程和该线程争用锁,因此就不需要再同步

偏向锁加锁流程:对象头标志位设为01,可偏向标志位设置为1,使用CAS操作将线程ID记录在对象头中。

如果有其他线程尝试加锁,偏向模式失效。若此时对象仍未锁定,恢复到未锁定状态(标志位01,是否偏向0),否则恢复到轻量级锁状态(标志位00,是否偏向为0)

处于偏向锁、重量级锁状态的对象,此时其一致性hash code怎么存?

对于偏向锁:

  • 已经计算过一致性hashcode的对象,无法进入到偏向锁状态
  • 已经处于偏向锁状态的对象,计算一致性hash code时偏向锁被撤销

对于重量级锁:

  • 对象头指针指向ObjectMonitor对象,包含保存Mark Word的字段