【深入理解JAVA虚拟机】读书笔记——线程安全与锁优化

学习参考资料:周志明老师的著作《深入理解Java虚拟机(第3版)》

1.什么是线程安全

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

2.线程安全的实现方式

如何实现线程安全和代码的编写具有很大的关系,但虚拟机提供的同步和锁机制也至关重要

2.1互斥同步

同步是指当多个线程访问共享数据时,保证在同一时刻只有一个线程在使用共享数据

互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。

因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

  • synchronized使用规则
    在Java里面,最基本的互斥同步手段是synchronized关键字,这是一种块结构的同步语法。使用时为其制定一把锁,线程只有拿到这把锁才可以执行同步块中的方法。如果并没有指定锁,那将根据 synchronized 修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。

  • 重量级锁
    执行成本的角度看,synchronized 持有锁是Java语言中一个重量级(Heavy-Weight)的操作。主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。

  • 正因为这样Java虚拟机会对所进行优化,后面会有写。

2.2非阻塞同步

  1. 互斥同步中即便没有线程之间竞争也还是会加锁,这将会导致进行核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
  2. 随着硬件指令集的发展,我们有了另外一个选择基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
  3. 使用乐观并发策略需要“硬件指令集的发展”才能实现,因为我们需要操作和冲突检测这两个步骤具备原子性,这两个需要靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
  • 测试并设置(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的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。

3.锁优化

3.1自旋锁与自适应自旋

在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。于是有了自旋锁(为了让线程等待,我们只需让线程执行一个忙循环也就是自旋)

  • 自旋锁的优点
    自旋锁在JDK 1.6中默认开启。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

  • 自旋锁的优化(自适应自旋)
    在JDK 6 中对自旋锁的优化,引入了自适应的自旋:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

3.2锁消除

锁消除是指虚拟机即时编译器在运行时,检测到某段需要同步的代码根本不可能存在竞争,就会将锁进行消除。

  • 锁消除在什么情况下发生
    锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
    比如下面这段例子:
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.3锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如示例3.2中所示,连续的 append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以(示例2-2)为例,就是扩展到第一个 append()操作之前直至最后一个 append()操作之后,这样只需要加锁一次就可以了。

也就是,当虚拟机检测到有一串连续且零碎的操作都使用同一个对象加锁,虚拟机会扩大范围只加一把锁

3.4轻量级锁

  • 轻量级锁与重量级锁对比
    自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

  • 轻量级锁何时发生
    顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁

Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息。。

当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁

3.5偏向锁

在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。

“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁

偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。


【深入理解JAVA虚拟机】读书笔记——线程安全与锁优化_第1张图片

后面还会陆陆续续更新这系列的读书笔记,期待您的关注~~

你可能感兴趣的:(JVM学习篇,java,jvm,java虚拟机,后端)