学习参考资料:周志明老师的著作《深入理解Java虚拟机(第3版)》
当多个线程同时访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,以及不需要在调用时进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
如何实现线程安全和代码的编写具有很大的关系,但虚拟机提供的同步和锁机制也至关重要
同步是指当多个线程访问共享数据时,保证在同一时刻只有一个线程在使用共享数据。
互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。
因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
synchronized
使用规则
在Java里面,最基本的互斥同步手段是synchronized
关键字,这是一种块结构的同步语法。使用时为其制定一把锁,线程只有拿到这把锁才可以执行同步块中的方法。如果并没有指定锁,那将根据 synchronized
修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。
重量级锁
从执行成本的角度看,synchronized 持有锁是Java语言中一个重量级(Heavy-Weight)的操作。主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。
正因为这样Java虚拟机会对所进行优化,后面会有写。
测试并设置(Test-and-Set)。
获取并增加(Fetch-and-Increment)。
交换(Swap)。
比较并交换(Compare-and-Swap,下文称CAS)。
加载链接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)。
如下面这个例子:
/**
* Atomic变量自增测试
*/
public class AtomicTest {
public static AtomicInteger race =new AtomicInteger(0);
private static final int THREADS_COUNT=20;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < THREADS_COUNT; i++) {
new Thread(){
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
race.incrementAndGet();//自增
}
}
}.start();
}
Thread.sleep(2000);//等待所有线程执行完成
System.out.println(race);//打印结果
}
}
测试结果为:20000
incrementAndGet()
方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。
在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。于是有了自旋锁(为了让线程等待,我们只需让线程执行一个忙循环也就是自旋)
自旋锁的优点
自旋锁在JDK 1.6中默认开启。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin
来更改。
自旋锁的优化(自适应自旋)
在JDK 6 中对自旋锁的优化,引入了自适应的自旋:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。
锁消除是指虚拟机即时编译器在运行时,检测到某段需要同步的代码根本不可能存在竞争,就会将锁进行消除。
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 StringBuffer.append()
方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,经过逃逸分析后会发现它的动态作用域被限制在 concatString()
方法内部。也就是 sb 的所有引用都永远不会逃逸到concatString()
方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如示例3.2中所示,连续的 append()
方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以(示例2-2)为例,就是扩展到第一个 append()
操作之前直至最后一个 append()
操作之后,这样只需要加锁一次就可以了。
也就是,当虚拟机检测到有一串连续且零碎的操作都使用同一个对象加锁,虚拟机会扩大范围只加一把锁。
轻量级锁与重量级锁对比
自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
轻量级锁何时发生
顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息。。
当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。
后面还会陆陆续续更新这系列的读书笔记,期待您的关注~~