volatile、synchronized原理与Java内存模型(JMM)

JMM

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。 其实我们的JAVA程序的执行在内存中是通过一条条指令(编译成字节码)来完成的,而且一行代码往往对应着一到多条指令。

JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

首先需要明确的一点是:这三个特性是针对多线程而言,单个线程的执行不存在任何问题。

我们都知道volatile可以保证可见性有序性;而synchronized最重要的功能就是保证原子性,它也可以保证可见性,并且在一定条件下也可以保证有序性,笼统的讲synchronized可以保证有序性是不准确的,稍后会解释。下面我将从JMM的角度解释一下volatilesynchronized的原理。

原子性

synchronized保证原子性是通过给对象加锁实现的,它实质上是保证了对同一对象加锁的多个线程中临界区代码的“同步”。

简单粗暴的理解就是同一时间只能有一个线程对同一对象加锁,只有加了锁才有权利执行临界区的代码,当发生线程上下文切换时,加锁线程并不会释放锁,这就会使后来竞争锁的线程陷入阻塞状态,然后再切换回来继续将加锁线程的临界区代码执行完,所以在线程的角度,一段临界区代码就是一个原子操作

可见性

退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

public class TestVisible {
    static boolean run = true;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            while(run){
            // ....
             }
        });
        t.start();

        sleep(1);
        run = false; // 线程t不会如预想的停下来
    }
}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  2. 因为 t 线程要频繁(如果仅读取几次,不会缓存)从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

看来是JVM好心办了坏事,怎么解决这个问题哪?volatilesynchronized都可以。

volatile原理

解决上述问题:
volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

class TestVisibleV {
    static volatile boolean run = true;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
// --------------读屏障------------------
            while(run){
                // ....
            }
        });
        t.start();

        sleep(1);
        run = false; // 线程t不会如预想的停下来
// --------------写屏障------------------
    }
}

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量执行写指令后会加入写屏障,写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 对 volatile 变量执行读指令前会加入读屏障,而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

用volatile修饰共享变量后

  • 当多个线程对共享变量进行操作后,会在此处建立一个写屏障,保证写屏障之前所有对共享变量的改动都同步到内存中,例如主线程t修改run后,会同步到内存中。
  • 当多个线程对共享变量进行操作前,会在此处建立一个读屏障,保证读屏障之后的所有共享变量的读取加载的是主存中最新数据,例如线程t读取run时,读取的都是内存中的最新数据。
synchronized原理

解决上述问题:

class TestVisibleS1 {
    static boolean run = true;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            synchronized (TestVisibleS1.class) {
                while(run){
                    try {
                        TestVisibleS1.class.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // ....
                }
            }
        });
        t.start();

        sleep(1);

        synchronized (TestVisibleS1.class) {
            run = false; // 线程t可以停下来
            TestVisibleS1.class.notify();
        }
    }
}

首先主线程sleep,t先获得锁,因为run为true,会进入while等待,此时释放锁,然后主线程获得锁,并改变run,唤醒t,释放锁的同时提交对run的更改。最后wait结束,重新加锁获得run的最新值为false,退出循环,线程死亡。

synchronized实现可见性的原理与volatile略有不同

  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中

其实synchronized是以临界区为单位与主内存中的共享变量进行交互的。注意一点,线程新建时会从主内存加载数据,线程死亡时也会自动提交更改。这类似于加锁和解锁时对于共享变量的操作。

有序性

指令重排序优化

实际上在CPU中,在不改变程序结果的前提下,有些指令的各个阶段可以通过重排序和组合来实现指令级并行。
例如:

// 可以重排的例子 
int a = 10; // 指令1 
int b = 20; // 指令2 
System.out.println( a + b );

// 不能重排的例子 
int a = 10;    // 指令1 
int b = a - 5; // 指令2

显然指令重排在单线程的环境下没有任何问题,不会影响结果,但在多线程下却有隐患,例如:

boolean ready = false;
int num = 0;
// 线程1 执行此方法
 public void actor1(I_Result r) {
     if(ready) {
         r.r1 = num + num;
     } else {
         r.r1 = 1;
     }
 }
 
// 线程2 执行此方法
 public void actor2(I_Result r) {
     num = 2;
     ready = true;
 }

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

  • 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  • 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

其实还有一种情况:

  • 情况4:线程2 先执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
volatile原理

解决方法:volatile 修饰的变量,可以通过读屏障写屏障保证指令重排在多线程环境下的正确性(一些教程里说“禁止指令重排”是不严谨的,我稍后会介绍)。

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

注意:保证写屏障之前的代码所对应的指令不会重排序到写屏障之后,并不是禁止重排序,写屏障之前的代码所对应的指令还是可以重排序的。

volatile boolean ready = false;
int num = 0;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
// --------------读屏障------------------
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

// 线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
// --------------写屏障------------------
    ready = true;
}

从上例可以看出,对ready加上volatile后,线程2对ready的写操作一定在最后。

注意,volatile不能解决指令交错,指令交错必须由synchronized解决(原子性):

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
synchronized原理

前面讲synchronized在一定条件下可以保证有序性,这个条件是什么哪?就是必须把共享变量的所有读写操作都包含在临界区内

仔细一想就会明白为什么了,其实跟读写屏障的作用差不多,synchronized的临界区其实就是一个屏障,它可以保证临界区内代码的指令的有效性,即使在临界区内产生了指令的重排,但期间并没有其他线程可以访问临界区内所使用的共享变量(因为被锁住了),所以可以保证有序性。

下面这个例子很好的体现了上述的诸多特性。

应用之double-checked locking 问题

如果是下面这样的设计:

final class Singleton {
    // 私有构造方法
    private Singleton() {
        //...
    }

    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        if (INSTANCE == null) {  // t1,t2线程可能同时进入,创建多个实例
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}

我们知道上面的代码有很大的问题,不能保证多线程安全,可能多个线程同时进入,创建了多个实例,所以要在getInstance加上synchronized,因为把锁加在静态方法上等价于把锁加在类对象上,所以可以写成下面这种形式:

final class Singleton {
    // 私有构造方法
    private Singleton() {
        //...
    }

    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (INSTANCE == null) {
                INSTANCE = new Singleton();
            }
            return INSTANCE;
        }
    }
}

这样不存在线程安全问题了,但是也不好,因为每次调用getInstance方法都要尝试加锁,会影响程序的性能,,我们改一下,尝试将锁的范围缩小,能不能做到只在第一次调用的时候加锁,有人想到了,就是dcl模式,也就是两次检查:

final class Singleton {
    // 私有构造方法
    private Singleton() {
        //...
    }

    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        if (INSTANCE == null ) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

但是上面的看似完美的代码真的没有问题吗,可能有人说没有问题啊,synchronized可以保证原子性、可见性和有序性啊,别忘了我们之前说的有序性的条件,这里满足吗?

没错,上面的代码并不能满足有序性,因为第一个if (INSTANCE == null )涉及到对共享变量的读操作,但并不在synchronized里。

存在这样一种特殊情况,线程1第一个调用了getInstance方法,并对类对象加了锁,但它内部临界区代码进行了指令重排,先对INSTANCE赋了值(对象的引用地址),然后再调用构造方法;此时线程2也调用了getInstance方法,很显然INSTANCE不是null,线程2就直接返回了一个尚未进行构造的对象,并且很可能开始使用了,这就产生了违反有序性的问题。

解决办法也很简单,在INSTANCE变量前加上volatile即可。

final class Singleton {
    // 私有构造方法
    private Singleton() {
        //...
    }

    private static volatile Singleton INSTANCE = null;

    public static Singleton getInstance() {
        if (INSTANCE == null ) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值