Java并发编程底层实现原理(不了解这个不敢说懂并发)

Posted 李子捌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程底层实现原理(不了解这个不敢说懂并发)相关的知识,希望对你有一定的参考价值。

写在前面

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要转换为汇编指令在CPU上执行,Java中所有的并发机制依赖于JVM的实现和CPU的指定。

1、volatile 的应用

在并发编程中synchronized和volatile关键字都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改了一个共享变量时,另一个线程能读到这个修改的值,如果volatile关键字使用得当的话,它会比synchronized使用的成本更低,因为它不会引起上下文切换和调度

1.1 连接CPU的相关术语与说明

术语英文单词术语描述
内存屏障memory barriers是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache lineCPU高速缓存中可以分配的最小单位。处理器填写缓存行时会加载整个缓存行,现代CPU 需要执行几百次CPU指令
原子操作atomic operations不可中断的一个或者一系列操作
缓存行填充cache line hit当处理器识别到从内存中读取操作数是缓存的,处理器读取整个缓存行到适当的缓存(L1 \\L2 \\L3)
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是内存
写命中write hit当处理器将操作数协会到一个内存缓存的区域时,他会首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器会将这个操作数写回到缓存,而不是写回到内存,这个操作就是写命中
写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域

1.2 volatile是如何保证内存可见性的

原理:被volatile修饰的变量,通过jvm最终生成的汇编指令会多出一行汇编代码,这行代码是Lock前缀的。

// java 代码
// instance 被 volatile关键字修饰
instance = new Singleton();

// 通过工具获取的JIT编译器生成汇编指令如下
0x01a3deld: movb $0.....: lock add1 $x0,(%esp);

Lock 前缀的指令在多核处理器下会引发两件事情

  • 将处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使在其他CPU缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存中的数据读到内存缓存(L1 L2或其他)后在进行操作,但操作完不知道何时写会内存。如果对申明了volatile的变量进行操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧值,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己的缓存是否过期了,当处理器发现自己缓存的数据对应的内存地址被修改,就会将当前处理器的缓存行设置为无效,当处理器对这个数据进行修改时,就会重新从内存中读取数据到缓存中。

2、synchronized的实现原理与应用

在多线程并发编程中synchronized一直是元老角色,很多人称呼它为重量级索。但是随着Java SE 1.6对synchronized进行了各种优化之后,有些情况synchronized它不在那么重了。接下来阐述的知识点是关于偏向锁、轻量级锁,以及锁的存储结构和升级过程。

synchronized在Java中三种表现形式

  • 对于普通同步方法,锁的是当前实例对象
  • 对于静态同步方法,锁的是类的class对象
  • 对于同步方法块,锁的是synchronized括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或者抛出异常时,必须释放锁。name锁到底存在哪里呢?锁里面存的又是什么信息呢?

在JVM规范中可以看到synchronized在JVM中的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现的细节不一样。代码块使用monitorenter和monitorexit指令实现,而方法同步使用的是另外一种情况,这个在JVM规范中并没有讲解。但是,方法的同步也可使用这两个指令来实现。

  • monitorenter指令是在编译后插入到同步代码块的开始位置
  • monitorexit指令插入到方法结束的位置和异常处
  • JVM要保证每个monitorenter必须有与之对应的monitorexit配对
  • 任何对象都有一个monitor与之关联
  • 当一个monitor被持有后,它将处于锁定状态
  • 线程执行monitorenter指令时,将会尝试获取对象对应的monitor的所有权,即尝试获得对象的锁

2.1 Java对象头

synchronize用的锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数据类型,则虚拟机用2个字宽存储对象头 。在32位虚拟机中1字宽等于4字节,即32bit。

Java对象头的长度

长度内容说明
32/64bitMark Word存储对象的hashCod或锁信息
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度(如果当前对象是数组)

Java对象头的Mark Word默认存储的是对象的hashCode、分代年龄和锁标志位。32位JVM的Mark Word的默认存储结构如表

Java对象头存储结构

锁状态25bit4bit1bit是否是偏向锁2bit锁标志位
无锁状态对象的hashCode对象的分代年龄001

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化,内容较为复杂,图先不讲了,有兴趣的可以查看相关书籍信息

2.2 锁升级与对比

Java SE 1.6 为了减少获得锁和释放锁带来性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个状态会随着竞争情况逐渐升级。注意:锁的升级是不可逆的,意味着偏向锁升级为轻量级锁之后是不能降级为偏向锁的。

2.1.1 偏向锁

HotSpot的作者研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取的,为了让线程获得锁的代价更低,引入了偏向锁。 注意:这个是设计偏向锁的原因和解决思路

2.1.1.1 偏向锁的获取

当一个线程访问同步代码块并获取锁时,会在对线头和栈帧中的锁记录中存储锁偏向的线程ID,以后该线程在进入和退出同步代码块的时候,不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对线头Mark Word里是否存储了当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要在测试一下Mark Word中偏向锁标识是否被设置成立1(表示当前是偏向锁):如果没有设置则使用CAS竞争锁;如果这事了,则尝试使用CAS将当前对象头的偏向锁指向当前线程。

2.1.1.2 偏向锁的撤销(非常妙这里)

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁是,持有偏向锁的线程才会释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁标记活着标记该对象不适合作为偏向锁,最后唤醒暂停的线程

2.1.1.3 关闭偏向锁

Java 1.6 1.7偏向锁是默认开启的,但是它在应用程序启动几秒钟后才会激活,我们可以修改JVM参数来关闭延迟,或者确定应用程序里所有的锁通常情况下都是出于竞争状态,可以直接关闭偏向锁

# 关闭偏向锁延迟
-XX:BiasedLockingStartupDelay=0

# 关闭偏向锁 程序默认进入轻量级锁状态
-XX:-UseBiasedLocking=false

2.1.2 轻量级锁

2.1.2.1 轻量级锁加锁

线程在执行同步代码块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试自旋获得锁。

2.1.2.2 轻量级锁解锁

轻量级锁解锁时,使用的是CAS操作将Displaced Mark Word替换回对象头,如果成功则表示没有竞争;如果失败,表示当前锁存在竞争,锁就会膨胀为重量级锁。 注意:由于自旋过程消耗CPU,为了避免无用的自旋,一当升级为重量级锁,那么就不会再恢复到轻量级锁状态。当前锁出于重量级锁状态时,其他线程尝试获取锁,都会被阻塞,当持有锁的线程释放锁后会唤醒这些线程,重新进行锁的争夺。

2.1.3 锁的优缺点对比

锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁过程不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差异如果线程存在锁竞争,会带来额外的锁撤销开销适用于只有一个线程访问同步代码块
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间,同步代码块执行速度非常快
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,响应时间慢追求吞吐量,同步块执行速度较长

3、Java中如何实现原子操作

在Java中可以通过锁和循环CAS的方式实现原子操作

3.1 使用循环CAS实现原子操作

JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现。自旋CAS实现的基本思路就是循环进行CAS操作知道成功为止,示例代码实现一个安全的计数器和非安全的计数器。

package com.liziba;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @auther LiZiBa
 * @date 2021/2/28 17:39
 * @description: 计数器实现
 **/
public class Counter {

    // 安全计数器统计数
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    // 非安全计数器统计数
    private int i = 0;

    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> threads = new ArrayList<>(600);
        long start = System.currentTimeMillis();

        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    // 非安全计数器
                    cas.count();
                    // 安全计数器
                    cas.safeCount();
                }
            });
            threads.add(t);
        }
        // 启动线程
        threads.forEach(t -> t.start());
        // join等待所有线程执行完毕
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 输出不安全计数器结果、安全计数器结果、程序执行时间
        System.out.println(cas.i);
        System.out.println(cas.atomicInteger.get());
        System.out.println(System.currentTimeMillis() - start);
    }

    /**
     * 安全计数器
     */
    private void safeCount() {
        i++;
    }

    /**
     * 非安全计数器
     */
    private void count() {
        for (;;) {
            int i = atomicInteger.get();
            // CAS 增加
            // 注意使用 ++i
            boolean set = atomicInteger.compareAndSet(i, ++i);
            // 设置成功退出死循环
            if (set) {
                break;
            }
        }
    }
}
# 运行结果
711319
1000000
265

Process finished with exit code 0

JDK 1.5开始,JDK并发包提供了一些类支持原子操作,如AtomicBoolean,AtomicInteger,AtomicLong,对应不同类型的原子操作,这些类提供了非常有用的工具方法,比如原子自增和自减等等。

3.1.1 CAS 实现原子操作的三大问题

  • ABA问题:因为CAS需要在操作值的时候,检查值是否发生了变化,如果没有变化则更新,但是如果一个值从A修改为B又修改为A,那么使用CAS就无法发现值发生了变化,但实际上发生了变化。解决方案如下

    • 使用版本号解决,将原本的A–>B–>A问题变成1A–>2B–>3A则可以解决
    • 使用JDK Atomic包里提供的AtomicStampedReference来解决ABA问题,这个类compareAndSet方法会首先比较当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等则以原子方式替换,源码如下。
      public boolean compareAndSet(V   expectedReference,
                                     V   newReference,
                                     int expectedStamp,
                                     int newStamp) {
            Pair<V> current = pair;
            return
                expectedReference == current.reference &&
                expectedStamp == current.stamp &&
                ((newReference == current.reference &&
                  newStamp == current.stamp) ||
                 casPair(current, Pair.of(newReference, newStamp)));
        }
    
  • 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的开销。解决这个问题需要JVM能支持处理器提供的pause指令,效率会有一定的提升。pause指令的两个作用如下

    • 延迟流水线执行指令(de-pipeline),使CPU不会消耗过多执行资源
    • 避免在退出循环时内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空
  • 只能保证一个共享变量的原子操作:对多个共享变量进行操作的时候CAS无法保证原子性,解决方案如下

    • 使用锁
    • 变量合并
    • 使用JDK提供的AtomicReference类来保证引用对象之间的原子性,将多个变量放置于对象中

3.2 使用锁机制实现原子操作

锁机制保证了只有获得了锁的线程才能操作锁定的内存区域。JVM内部实现了很多锁,偏向锁、轻量级锁、重量级锁。但是除了偏向锁,JVM实现锁的方式都使用了循环CAS机制,即一个线程想进入同步代码块的时候,使用循环CAS的方式获取锁,当它退出同步代码块的时候使用循环CAS来释放锁

参考资料

  • 《Java并发编程的艺术》-- 方腾飞 魏鹏 程晓明 著
  • 百度百科
  • CSDN部分博客

以上是关于Java并发编程底层实现原理(不了解这个不敢说懂并发)的主要内容,如果未能解决你的问题,请参考以下文章

[并发编程的艺术] 02-Java并发机制的底层实现原理

大牛聊Java并发编程原理之 线程的互斥与协作机制

深入浅出Java并发编程指南「原理分析篇」底层角度去分析线程的实现原理

Java并发编程原理与实战三十七:线程池的原理与使用

java并发编程艺术学习第二章 java并发机制的底层实现原理 学习记录 volatile

JAVA并发编程:java并发机制的底层实现原理