likes
comments
collection
share

你好,volatile!你好,synchronized!

作者站长头像
站长
· 阅读数 3

SynchronizedVolatile是两个在Java中用于处理多线程编程的关键字,它们分别用于实现线程同步确保可见性。在Java多线程编程中,二者是互补的存在,而不是对立的存在。

volatile

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

指令重排

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

什么是指令重排?

简单来说,就是指在程序中写的代码,在执行时并不一定按照写的顺序。

Java指令重排是一种编译器和JVM(Java虚拟机)优化技术,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序输入代码中的顺序一致。这就是指令重排序。

什么时候会发生指令重排?

  1. 编译器重排:编译器在生成字节码或本机代码时可能会重新排列指令,以优化执行路径。这种重排通常是在不改变程序语义的前提下进行的。
  2. 处理器重排:现代处理器通常具有多级流水线,它们可以重新排列执行指令以充分利用硬件资源。处理器重排是在指令级别进行的,它不会改变程序的语义,但可能会影响线程之间的可见性。
  3. JVM重排:JVM也可能会重新排列Java字节码指令以提高性能。这种重排也是在不改变程序语义的前提下进行的。

一个指令重排的复现案例

定义四个静态变量x,y,a,b,每次循环时让他们都等于0,接着用两个线程,第一个线程执行a=1;x=b;第二个线程执行b=1;y=a。

从逻辑上来讲,这段程序应该有3个结果:

  1. 当第一个线程执行到a=1的时候,第二个线程执行到了b=1,最后x=1,y=1
  2. 当第一个线程执行完,第二个线程才刚开始,最后x=0,y=1
  3. 当第二个线程执行完,第一个线程才开始,最后x=1,y=0

理论上无论怎么样都不可能x=0,y=0;

 ​ public class VolatileReOrderDemo {
     private static int x = 0, y = 0;
     private static int a = 0, b = 0;
 ​
     public static void main(String[] args) throws InterruptedException{
         int i = 0;
 ​
         do {
             i++;
             x = 0;  y = 0;
             a = 0;  b = 0;
 ​
             // 开两个线程,第一个线程执行 a=1;x=b;   第二个线程执行 b=1;y=a
             Thread thread1 = new Thread(new Runnable() {
                 @Override
                 public void run() {
                     // 线程1会比线程2先执行,因此用nanoTime让线程1等待线程2 0.01毫秒
                     shortWait(1000);
 ​
                     a = 1;
                     x = b;
                 }
             });
 ​
             Thread thread2 = new Thread(new Runnable() {
                 @Override
                 public void run() {
                     b = 1;
                     y = a;
                 }
             });
 ​
             thread1.start();
             thread2.start();
 ​
             thread1.join();
             thread2.join();
 ​
             // 两个线程都执行完成后拼接结果
             String result = String.format("第 %d 次执行:\tx=%d\ty=%d", i, x, y);
             System.out.println(result);
 ​
         } while (x != 0 || y != 0);
     }
 ​
     /**
      * 使用了一个循环来等待一段时间,但不涉及锁或通知机制。它不会释放锁,也不会等待其他线程的通知。
      * 仅用于简单的等待一段时间,而不是线程之间的协作。
      *
      * @param interval 等待的时间间隔
      */
     public static void shortWait(long interval){
         long start = System.nanoTime();
         long end;
 ​
         do {
             end = System.nanoTime();
         }while (start + interval >= end);
     }
 }
 ​

实际上,当程序运行几万或几十几百万次后,会出现x=0,y=0;的结果:

 第 4870627 次执行:  x=0 y=1
 第 4870628 次执行:  x=0 y=1
 第 4870629 次执行:  x=0 y=1
 第 4870630 次执行:  x=0 y=1
 第 4870631 次执行:  x=0 y=0

这就是因为指令被重排序了,x=b先于a=1执行,y=a先于b=1执行。

volatile 禁止指令重排

如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

内存屏障 是一个CPU的指令,它可以保证特定操作的执行顺序。

volatile关键字确保了禁止特定类型的指令重排。在volatile变量的读操作和写操作周围,编译器和处理器会添加内存屏障(Memory Barrier),确保写操作不会被重排到读操作之前,也不会被重排到读操作之后。这意味着其他线程在读取volatile变量时,将看到写操作的结果,而不会看到它们之间的重排序。

单例模式:一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点以获取该实例。单例模式通常用于那些需要在应用程序中全局共享一个实例的情况,以确保该实例在整个应用程序生命周期内只被创建一次。

 /**
  * 双重校验锁实现对象单例
  */
 public class Singleton {
     private volatile static Singleton uniqueInstance;
 ​
     private Singleton(){
 ​
     }
 ​
     public static Singleton getUniqueInstance(){
         // 判断是否已经实例化过,没有实例化过才进入加锁代码
         if(uniqueInstance == null){
             // 类对象加锁
             synchronized (Singleton.class){
                 if(uniqueInstance == null){
                     uniqueInstance = new Singleton();
                 }
             }
         }
         return uniqueInstance;
     }
 }
 ​

uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

volatile保证变量的可见性

设计如下的程序验证:

 public class VolatileVisibilityDemo {
     private volatile boolean flag = false;
 ​
     public void toggleFlag(){
         // 修改 flag 的值
         flag = !flag;
     }
 ​
     public boolean isFlag(){
         // 读取 flag
         return flag;
     }
 ​
     public static void main(String[] args) {
         VolatileVisibilityDemo example = new VolatileVisibilityDemo();
 ​
         // 线程 A:修改flag
         Thread threadA = new Thread(() -> {
             try {
                 Thread.sleep(1000); // 让 B 线程有足够的时间启动
             } catch (InterruptedException e) {
                 e.printStackTrace();
                 throw new RuntimeException(e);
             }
             example.toggleFlag();
             System.out.println("Flag has been set to true");
         });
 ​
         // 线程 B:检查flag的值
         Thread threadB = new Thread(() -> {
             while (!example.isFlag()){
                 // 等待 flag 变成 true
             }
             System.out.println("Flag is now true");
         });
 ​
         threadA.start();
         threadB.start();
     }
 ​
 }

在这个示例中,有两个线程,线程A负责修改flag的值为true,而线程B负责检查flag的值是否为true。由于flag被声明为volatile,线程B能够立即看到线程A对flag的修改,因此线程B将在flag变为true后输出相应的信息。

如果将flag声明为非volatile的话,线程B可能会永远等待下去,因为不保证可见性,线程B无法及时获取到线程A对flag的修改。这就是volatile关键字的作用,确保变量的可见性,适用于需要跨线程通信的情况。

volatile不保证原子性

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

原子性(Atomicity)是多线程编程中的一个重要概念,它指的是一个操作是不可分割的,要么全部执行,要么全部不执行。如果一个操作是原子性的,那么在多线程环境下,其他线程无法在执行该操作的过程中干扰或修改其状态,从而确保操作的一致性和完整性。

验证示例代码:

 /**
  * 验证 volatile 不保证变量操作的原子性
  */
 ​
 public class VolatileAtomicityDemo {
     public volatile static int inc = 0;
 ​
     public void increase(){
         inc++;
     }
 ​
     public static void main(String[] args) throws InterruptedException{
         int numThreads = 5;
         int numIteration = 500;
 ​
         ExecutorService threadPool = Executors.newFixedThreadPool(numThreads);
 ​
         VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
 ​
         for (int i = 0; i < numThreads; i++){
             threadPool.execute(() -> {
                 for (int j = 0; j < numIteration; j++){
                     volatileAtomicityDemo.increase();
                 }
             });
         }
 ​
         // 等待 1.5 秒,保证上面的程序执行完成
         Thread.sleep(15000);
 ​
         System.out.printf("共有%d个线程,每个线程迭代%d次。本应增长%d次!\n",
                 numThreads, numIteration, numThreads * numIteration);
 ​
         System.out.printf("实际增长次数为:%d\n", inc);
 ​
         threadPool.shutdown();
     }
 }
 ​

输出为:

 共有5个线程,每个线程迭代500次。本应增长2500次!
 实际增长次数为:2417

正常情况下,运行上面的代码理应输出 2500。但真正运行了上面的代码之后,你会发现很多时候结果都小于 2500

也就说明,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

事实上,inc++ 是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。

synchronized

synchronized 是 Java 中的一个关键字,中文意思为“同步”、“协调”,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

具体使用场景

实例方法与静态方法

在Java中,方法可以分为两大类:实例方法和静态方法。这两种方法有不同的特点和用途。

  1. 实例方法(Instance Method):

    • 实例方法是与对象实例相关联的方法。
    • 它可以访问和操作对象的实例变量(成员变量)。
    • 实例方法必须通过对象调用,因为它们依赖于对象的状态。
    • 通常用于封装对象的行为和操作。

例子:

 public class Car {
     String model;
 ​
     public void start() {
         System.out.println("Starting the " + model + " car.");
     }
 }
 ​
 public class Main {
     public static void main(String[] args) {
         Car myCar = new Car();
         myCar.model = "Toyota";
         myCar.start(); // 调用实例方法
     }
 }

在上面的示例中,start 是一个实例方法,它操作了 Car 类的实例变量 model,并且必须通过 myCar 实例调用。

  1. 静态方法(Static Method):

    • 静态方法与类相关联,而不是与对象实例相关联。
    • 它不可以访问对象的实例变量,因为它与特定实例无关。
    • 静态方法可以直接通过类名调用,无需创建对象。
    • 通常用于执行与类相关的操作,不依赖于特定实例。

例子:

 public class MathUtils {
     public static int add(int a, int b) {
         return a + b;
     }
 }
 ​
 public class Main {
     public static void main(String[] args) {
         int result = MathUtils.add(5, 3); // 调用静态方法
         System.out.println("Result: " + result);
     }
 }

在上面的示例中,add 是一个静态方法,它与特定的对象实例无关,可以直接通过类名 MathUtils 调用。

实例方法与对象实例相关联,可以访问实例变量,静态方法与相关联,不依赖于对象实例,不能访问实例变量。

synchronized修饰实例方法

当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

 synchronized void method() {
     ...
 }

synchronized修饰静态方法

当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

 synchronized static void method() {
     ...
 }

synchronized修饰代码块

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁

构造方法不能使用 synchronized 关键字修饰。 因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 保证操作的可见性

 public class SynchronizedVisibilityDemo {
     private boolean flag = false;
 ​
     public synchronized void writeData(){
         flag = true;
     }
 ​
     public synchronized boolean readData(){
         return flag;
     }
 ​
     public static void main(String[] args) {
         SynchronizedVisibilityDemo demo = new SynchronizedVisibilityDemo();
 ​
         Thread writerThread = new Thread(() -> {
             // 保证让读线程先运行
             try {
                 Thread.sleep(1500);
             } catch (InterruptedException e) {
                 throw new RuntimeException(e);
             }
             System.out.printf("Data is %s, ready to write to %s.\n", demo.flag, !demo.flag);
             demo.writeData();
         });
 ​
         Thread readerThread = new Thread(() -> {
             while (true){
                 // 等待数据变为 true
                 if (demo.readData()) break;
 ​
             }
             System.out.println("Data is true now!");
         });
 ​
 ​
         readerThread.start();
         writerThread.start();
     }
 }
 ​

在这个示例中,假如不加 synchronized 关键字,写线程对 flag 的操作对于读线程来说是不可见的,读线程会一直等待下去。

synchronized 保证操作的原子性

volatile 的验证方法相似。不加 synchronized 修饰,结果可能为:

 共有5个线程,每个线程迭代1000次。增长后 Counter 应为 5000
 实际 Counter: 4395

测试代码:

 public class SynchronizedAtomicityDemo {
     private int counter = 0;
 ​
     public synchronized void increase(){
         counter++;
     }
 ​
     public int getCounter(){
         return counter;
     }
 ​
     public static void main(String[] args) throws InterruptedException {
         int numThreads = 5;
         int numIteration = 1000;
 ​
         ExecutorService threadPool = Executors.newFixedThreadPool(numThreads);
 ​
         SynchronizedAtomicityDemo demo = new SynchronizedAtomicityDemo();
 ​
         for (int i = 0; i < numThreads; i++) {
             threadPool.execute(() -> {
                 for (int j = 0; j < numIteration; j++) {
                     demo.increase();
                 }
             });
         }
 ​
         // 关闭线程池并等待所有任务完成
         threadPool.shutdown();
 ​
         boolean termination = threadPool.awaitTermination(1, TimeUnit.MINUTES);
 ​
         System.out.printf("共有%d个线程,每个线程迭代%d次。增长后 Counter 应为 %d!\n",
                 numThreads, numIteration, numThreads * numIteration);
 ​
         System.out.println("实际 Counter: " + demo.getCounter());
     }
 }

synchronized 底层原理

对于修饰代码块的情况

 public class SynchronizedCodeBlockDemo {
     public void method(){
         synchronized (this){
             System.out.println("Synchronized 代码块");
         }
     }
 ​
     public static void main(String[] args) {
         SynchronizedCodeBlockDemo demo = new SynchronizedCodeBlockDemo();
         demo.method();
     }
 }
 ​

将代码使用javac编译,之后再执行javap -c -s -v -l SynchronizedCodeBlockDemo.class 命令后,可以看到 method 方法反编译的结果:

你好,volatile!你好,synchronized!

解读

 public void method();:这是方法的定义,名称为method,没有参数,返回类型为void,并且被声明为public。
 ​
 descriptor: ()V:这部分描述了方法的描述符。()表示方法没有参数,V表示返回类型为void。
 ​
 flags: (0x0001) ACC_PUBLIC:这是方法的修饰符,表示这个方法是public的。
 ​
 Code::以下是方法的字节码指令。
 ​
 stack=2, locals=3, args_size=1:这是方法执行时的堆栈深度、本地变量数和参数数量的信息。
 ​
 0: aload_0:加载对象引用到操作数栈上。
 ​
 1: dup:复制栈顶的值并将副本压入栈。
 ​
 2: astore_1:将栈顶的值存储到局部变量1中。
 ​
 3: monitorenter:进入对象监视器(锁定对象),用于同步块的开始。
 ​
 4: getstatic #7:从静态字段#7(可能是java/lang/System.out的字段)获取一个值。
 ​
 7: ldc #13:将常量#13(可能是字符串常量"Synchronized 代码块")加载到操作数栈上。
 ​
 9: invokevirtual #15:调用虚拟方法#15(可能是java/io/PrintStream.println)。
 ​
 12: aload_1:将局部变量1加载到栈上。
 ​
 13: monitorexit:退出对象监视器,用于同步块的结束。
 ​
 14: goto 22:跳转到第22条指令。
 ​
 17: astore_2:将栈顶的异常对象存储到局部变量2中。
 ​
 18: aload_1:将局部变量1加载到栈上。
 ​
 19: monitorexit:再次尝试退出对象监视器,用于异常处理。
 ​
 20: aload_2:将异常对象加载到栈上。
 ​
 21: athrow:抛出异常。
 ​
 22: return:返回指令,方法执行结束。
 ​
 Exception table::这是异常处理表,描述了哪些异常在哪些范围内被处理。
 ​
 LineNumberTable::这是行号表,指明了字节码指令和源代码之间的对应关系。
 ​
 LocalVariableTable::这是本地变量表,列出了本地变量的信息。

需要注意 Code 部分的第3、13、19,这表明 synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放

对于修饰方法的情况

同样的方式操作以下代码:

 public class SynchronizedMethodDemo {
     public synchronized void method(){
         System.out.println("Synchronized 方法");
     }
 ​
     public static void main(String[] args) {
         SynchronizedMethodDemo demo = new SynchronizedMethodDemo();
         demo.method();
     }
 }
 ​

你好,volatile!你好,synchronized!

与修饰代码块相对比, synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁

flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED:这是方法的修饰符,表示这个方法是public的,并且使用了synchronized修饰符。

volatilesynchronized 的本质都是对对象监视器 monitor 的获取。

synchronized 和 volatile 的区别

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。