深入理解Java并发机制之volatile和synchronized

819 阅读17分钟

本文为《Java并发编程的艺术》一书第二,三章的读书笔记。这内容之前看过几遍,不过容易忘,索性记下来吧,忘了就在看看,放在网上也方便- -。

前言

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

Java内存模型(JMM)

线程之间的通信机制

线程之间的通信机制有两种:

  1. 共享内存:在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态 进行隐式通信。
  2. 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

Java内存模型

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行。JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量(实例域,静态域和数组元素)存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。JMM的抽象如下图所示:

image.png

从上图来看,线程A与线程B之间通信,必须经历下面2个步骤:

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序

happens-before规则

JMM通过happens-before的概念来阐述操作之间的内存可见性,它实际上可以理解是JMM禁止某种类型的处理器重排序所建立的规则

  1. 程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作。在JMM里一个线程其实只要执行结果一样,是允许重排序的,这边的happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。
  2. 监视器锁规则:对一个线程的解锁,happens-before于随后对这个线程的加锁。
  3. volatile变量规则: 对一个volatile域的写,happens-before于后续对这个volatile域的读。
  4. 传递性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C。
  5. start()规则: 如果线程A执行操作ThreadB_start()(启动线程B) , 那么A线程的ThreadB_start() happens-before 于B中的任意操作。
  6. join()原则: 如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. interrupt()原则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
  8. finalize()原则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

volatile

volatile是轻量级的synchronize,它在多处理器开发中保证了共享变量的“可见性”。 可见性:当一个线程修改一个共享变量时,另外一个线程能立即读到这个修改的值。

volatile的定义:Java编程语言允许线程访问共享变量。如果一个字段被申明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile的实现原理:有volatile修饰的共享变量的代码生成汇编代码时会多出一个Lock前缀的指令。

Lock前缀指令实际相当于一个内存屏障,它提供了以下功能:

  1. 缓存失效
  • 使得当前CPU的cache数据写回到系统内存

  • 写回到内存的操作,使得其他CPU的cache数据无效

  1. 禁止重排序
    • 重排序时不能把后面的指令重排序到内存屏障之前的位置

volatile变量具有下列特性:

  1. 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。volatile变量的读-写可以实现线程之间的通信。

  2. 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile变量++的这种符合操作不具有原子性。

volatile变量++操作不具备原子性,假设线程A,读取了inc的值为10,这时候线程B也读取了inc 10并进行自增且写入了主存。但是,由于A线程已经读取过了,这时候inc值还是10,自增之后还是11,写回主存。这种情况下,虽然两个线程执行了两次increase(),结果却只加了一次。

sychronized

sychronized是Java实现同步的基础,正确的使用可以保证线程安全,解决多线程中的并发同步问题。Java的所有对象都有一个互斥锁,这个锁由JVM自动获取和释放。

应用场景

sychronized的应用场景大致有两种:

对象锁

对象锁的作用:同步实例方法的调用。以下两种使用方式都是对象锁:

  1. 修饰实例方法的代码块,锁的是synchronized括号里配置的对象。
        public void run() {
            synchronized (this) {
                ...
            }
        }
  1. 修饰实例方法,锁的是当前实例对象。
        private synchronized void runInfo() {
            ...
        }
  1. 修饰静态变量,锁的申明静态变量类的对象实例
        private static Object object = new Object();
        @Override
        public void run() {
            synchronized (object) {
                ...
            }
        }

类锁

类锁的作用:同步静态方法的调用或者是同步类锁修饰方法的调用(在类生命周期的“加载”阶段,会生成加载类的一个java.lang.class对象实例,作为方法区在这个类的各种数据访问的入口)。以下两种使用方式都是类锁:

  1. 修饰Class对象,锁的是Class对象
        public void run() {
            synchronized (Timer.class) {
				...
            }
        }
  1. 修饰静态方法,锁的是所在类的Class对象
        private static synchronized void runInfo() {
            ...
        }

Demo程序

4个Demo共用一个Main函数

    public static void main(String[] args) {
        Timer timer = new Timer();
        Thread thread1 = new Thread(new Timer(), "Thead-1");
        Thread thread2 = new Thread(new Timer(), "Thead-2");
        Thread thread3 = new Thread(timer, "Thead-3");
        Thread thread4 = new Thread(timer, "Thead-4");

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
对象锁1:修饰实例方法的代码块
    static class Timer implements Runnable {
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (this) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. Thread-1,2,3 为三个独立的对象实例,所以起止时间一样。Thread3,4公用一个对象实例。对象锁保证所修饰的代码块,在同一个时间只能有一个持该对象锁的线程进行访问同步代码块。
  2. 同时位于同步代码块外的区域各个线程可以并发执行到。
threadInfo: OuterThead-4,Start Time:20:04:29
threadInfo: OuterThead-2,Start Time:20:04:29
threadInfo: OuterThead-3,Start Time:20:04:29
threadInfo: OuterThead-1,Start Time:20:04:29
threadInfo: InnerThead-4,Start Time:20:04:29
threadInfo: InnerThead-2,Start Time:20:04:29
threadInfo: InnerThead-1,Start Time:20:04:29
threadInfo:Thead-4,End Time:20:04:34
threadInfo:Thead-2,End Time:20:04:34
threadInfo:Thead-1,End Time:20:04:34
threadInfo: InnerThead-3,Start Time:20:04:34
threadInfo:Thead-3,End Time:20:04:39
对象锁2:修饰实例方法
    static class Timer implements Runnable {
        @Override
        public synchronized void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        }
    }
  1. 同上的对象锁,Thread-1,2,3的三个对象实例独立,所以3个线程并发同时执行,所以起止时间一样。Thread3,4公用一个对象实例,同步的方法由Thread3,4顺序执行。
  2. 同步范围是整个方法。
threadInfo: OuterThead-1,Start Time:20:12:00
threadInfo: OuterThead-3,Start Time:20:12:00
threadInfo: OuterThead-2,Start Time:20:12:00
threadInfo: InnerThead-1,Start Time:20:12:00
threadInfo: InnerThead-3,Start Time:20:12:00
threadInfo: InnerThead-2,Start Time:20:12:00
threadInfo:Thead-1,End Time:20:12:05
threadInfo:Thead-3,End Time:20:12:05
threadInfo:Thead-2,End Time:20:12:05
threadInfo: OuterThead-4,Start Time:20:12:05
threadInfo: InnerThead-4,Start Time:20:12:05
threadInfo:Thead-4,End Time:20:12:10
对象锁3:修饰静态变量
    static class Timer implements Runnable {
        private static Object object = new Object();
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (object) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. 这里的对象锁有些特殊,由于静态变量的实例对象只有一份,所以4个线程在遇到同步块时需要排队执行同步代码块,谁持有锁就执行,执行完成后释放锁,由下一个线程获取锁执行。
  2. 同时位于同步代码块外的区域各个线程可以并发执行到。
threadInfo: OuterThead-4,Start Time:15:36:45
threadInfo: OuterThead-3,Start Time:15:36:45
threadInfo: OuterThead-2,Start Time:15:36:45
threadInfo: OuterThead-1,Start Time:15:36:45
threadInfo: InnerThead-4,Start Time:15:36:45
threadInfo:Thead-4,End Time:15:36:50
threadInfo: InnerThead-1,Start Time:15:36:50
threadInfo:Thead-1,End Time:15:36:55
threadInfo: InnerThead-2,Start Time:15:36:55
threadInfo:Thead-2,End Time:15:37:00
threadInfo: InnerThead-3,Start Time:15:37:00
threadInfo:Thead-3,End Time:15:37:05
类锁1:修饰Class对象
    static class Timer implements Runnable {
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (Timer.class) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. 类锁保证所修饰的代码块,在同一个时间只能有一个持该类锁的线程进行访问同步代码块。
  2. 同时位于同步代码块外的区域各个线程可以并发执行到。
  3. 锁的是类的Class对象,java.lang.class。
threadInfo: OuterThead-2,Start Time:20:17:46
threadInfo: OuterThead-1,Start Time:20:17:46
threadInfo: OuterThead-3,Start Time:20:17:46
threadInfo: OuterThead-4,Start Time:20:17:46
threadInfo: InnerThead-2,Start Time:20:17:46
threadInfo:Thead-2,End Time:20:17:51
threadInfo: InnerThead-4,Start Time:20:17:51
threadInfo:Thead-4,End Time:20:17:56
threadInfo: InnerThead-3,Start Time:20:17:56
threadInfo:Thead-3,End Time:20:18:01
threadInfo: InnerThead-1,Start Time:20:18:01
threadInfo:Thead-1,End Time:20:18:06
类锁2:修饰静态方法
    static class Timer implements Runnable {
        @Override
        public void run() {
            runInfo();
        }

        private static synchronized void runInfo() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        }
    }
  1. 类锁保证所修饰的代码块,在同一个时间只能有一个持该类锁的线程进行访问同步代码块。
  2. 修饰静态方法的类锁同步的范围是整个静态方法。
  3. 锁的是类的Class对象,java.lang.class。
threadInfo: OuterThead-1,Start Time:20:27:15
threadInfo: InnerThead-1,Start Time:20:27:15
threadInfo:Thead-1,End Time:20:27:20
threadInfo: OuterThead-3,Start Time:20:27:20
threadInfo: InnerThead-3,Start Time:20:27:20
threadInfo:Thead-3,End Time:20:27:25
threadInfo: OuterThead-4,Start Time:20:27:25
threadInfo: InnerThead-4,Start Time:20:27:25
threadInfo:Thead-4,End Time:20:27:30
threadInfo: OuterThead-2,Start Time:20:27:30
threadInfo: InnerThead-2,Start Time:20:27:30
threadInfo:Thead-2,End Time:20:27:35

synchronized的实现原理

JVM基于进入和退出monitor对象来实现方法同步和代码块同步。

代码块同步

对于代码块同步:

  1. JVM会在编译后将monitorenter指令插入到同步代码块的开始位置,将monitorexit指令插入到方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit与之匹配。
  2. 任何对象都有一个monitor对象与之关联(对象头的Mark Word中会存指向互斥量monitor的指针),当一个monitor对象被持有后,它将处于锁定状态。
  3. 线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁,过程如下:
  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1(可重入)。
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

方法同步

对于方法同步:

  1. JVM会在编译后在同步方法的常量池中放入ACC_SYNCHRONIZED标志。
  2. 当线程访问方法时,会先检查是否有ACC_SYNCHRONIZED,如果有设置,将会尝试获取对象所对应的monitor的所有权。获取之后再进行方法的执行。
  3. 如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

其实,不管是对象锁还是类锁,“锁记录”都是存储在Java对象中,只不过类锁的锁记录是存储在java.lang.class的对象实例中,对象锁的锁记录是存储在其他类的对象实例中。这就引出了我们下面的内容。

Java对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

image.png

对象头

以JDK8 64位的HotSpot虚拟机而言,对象头包括三部分信息:

  1. Mark Word: 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等 。大小为8个字节。

  2. Class Word: 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例 。JDK 8默认开启指针压缩的情况下,大小为4个字节。

  3. Array Length:只有对象是Java数组实例的时候,对象头才包含这个部分。存储数组的长度。大小为4个字节。

实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

对齐填充

对齐填充可能存在,它仅仅起着占位符的作用。HotSpot VM 要求对象的大小必须是8字节的整数倍。

从Java对象的内存布局我们可以发现,锁的相关信息就存储在Java对象头的Mark中。

synchronized锁的优化

在JDK 1.6之前,synchronized锁都是重量级锁,通过互斥量monitor来实现。从JDK 1.6开始,HotSpot虚拟机团队引入了适应性自旋、轻量级锁和偏向锁等锁优化内容。这些锁优化是虚拟机根据竞争情况自行决定的,都封装到synchronized的实现中了。

线程的阻塞和唤醒需要CPU从用户态转为核心态 ,而很多时候通过synchronized加锁进行同步控制的时候,锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

内核态:CPU可以访问内存所有数据, 包括外围设备, 例如硬盘, 网卡. CPU也可以将自己从一个程序切换到另一个程序。

用户态:只能受限的访问内存, 且不允许访问外围设备. 占用CPU的能力被剥夺, CPU资源可以被其他程序获取。

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁

偏向锁的加锁,当线程进入和退出同步块时,需要经历几个测试步骤:

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

偏向锁的撤销,当其他线程尝试竞争偏向锁时,就会释放锁,锁的撤销,需要等待全局安全点,分为以下几个步骤:

  1. 暂停拥有偏向锁的线程,检查线程是否存活

  2. 处于非活动状态,则设置为无锁状态

  3. 存活,则重新偏向于其他线程或者恢复到无锁状态或者标记对象不适合作为偏向锁(升级为轻量级锁)

  4. 唤醒线程

轻量级锁

轻量级锁的加锁

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

轻量级锁解锁

  1. 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象。

  2. 如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

参考与感谢

  1. www.cnblogs.com/uoar/p/1167…
  2. juejin.cn/post/684490…
  3. juejin.cn/post/684490…
  4. blog.csdn.net/carson_ho/a…
  5. segmentfault.com/a/119000000…