JAVA内存模型
内存模型产生的背景
在介绍 Java 内存模型之前,我们先了解一下物理计算机中的并发问题,理解这些问题可以搞清楚内存模型产生的背景。
物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机的解决方案对虚拟机的实现有相当的参考意义。
现在cpu和内存的交互大致如下:
当程序在运行过程中,会将运算需要的数据从主内存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L2),三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。
在有了多级缓存之后,程序的执行就变成了:
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
单核CPU只含有一套L1,L2,L3缓存;
如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
解决了处理器和内存的矛盾(一快一慢),但是引来的新的问题:缓存一致性
。
缓存一致性
除了增加高速缓存以外,为了更充分利用处理器内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句执行的先后顺序与输入代码中的顺序一致
,这个是处理器的优化执行;还有一个就是编程语言的编译器也会有类似的优化,比如做指令重排来提升性能。
Java 内存模型(JMM)
JAVA内存模型(JMM)
jvm是运行所有Java程序的抽象计算机,是Java语言的运行环境。Java语言的一个非常重要的特点就是与平台的无关性。
Java 虚拟机规范中试图定义一种内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。这就是 Java Memory Model,简称 JMM。
内存模型概述
Java 内存模型的主要目标是定义程序中各个变量的访问规则
,也就是在虚拟机中将变量存储到内存以及从内存中取出变量
(这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量,而对于局部变量这类的,属于线程私有,不会被共享)这类的底层细节,从而保证共享内存的原子性、可见性、有序性
。
内存基础操作流程
上图描述了一个多线程执行场景。图片来源。
线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。但是线程不能直接读写主内存的共享变量,每个线程都有自己的工作内存,线程需要读写主内存的共享变量时需要先将该变量拷贝一份副本到自己的工作内存,然后在自己的工作内存中对该变量进行所有操作,线程工作内存对变量副本完成操作之后需要将结果同步至主内存。
为了更好理解内存的交互操作,以线程通信为例,我们看看具体如何进行线程间值的同步。
jmm定义了8种原子操作:
lock (锁定)
,作用于主内存的变量,它把一个变量标识为一条线程独占的状态。unlock (解锁)
,作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。read (读取)
,作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。load (载入)
,作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。use (使用)
,作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。assign (赋值)
,作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store (存储)
,作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。write (写入)
,作用于主内存的变量,它把 Store 操作从工作内存中得到的变量的值放入主内存的变量中。
原子性、可见性、有序性
Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。
原子性
原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
public class TestThread {
private static volatile Integer num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100000; i++) { num++;}
System.out.printf("减法执行完毕\n");
downLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) { num--;}
System.out.printf("加法执行完毕\n");
downLatch.countDown();
}).start();
downLatch.await();
System.out.printf("执行结果: %d\n", num);
}
}
执行结果: -9609
执行结果: -199
执行结果: 123
对于共享变量访问的一个操作,如果对于除了当前执行线程以外的任何线程来说,都是不可分割的,那么就是具有原子性。
int a = 10; // 原子
a++; // 非原子
int b=a; // 非原子
a = a+1; // 非原子
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
i++;
// 可以看到,加这行打印与不加这行打印,执行的结果完全不同。WTF
// System.out.println("i=" + i);
}
System.out.printf("********** Thread 跳出, i=%d **********\n", i);
}).start();
Thread.sleep(100);
flag = false;
System.out.printf("********** main thread 结束, i=%d **********\n", i);
}
这段代码的执行结果可能会有点出乎意料。
在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的可见性问题。关于代码中的一段注释掉的代码,放开这段输出,就不会死循环,WTF??这个问题后面完全搞清楚了再来补充。关于可见性问题,可以参考一下这篇博客。https://www.jianshu.com/p/6abcddd04f4e
有序性
关于有序性,主要是重排序造成的。
重排序是对内存访问操作的一种优化,他可以在不影响单线程程序正确性的前提下进行一定的调整,进而提高程序的性能 。但是对于多线程场景下,就可能产生一定的问题 。
看一下示例代码:
public class TestThread1_1_Order {
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 10000; i++) {
new Test().deal();
}
}
public static class Test {
private int x = 0, y = 0;
private int a = 0, b = 0;
private void deal() throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("x=" + x + ",y=" + y + ";");
}
}
}
}
输出结果是:x=0,y=1;、x=1,y=0;、x=1,y=1; 这三种结果,因为可能是先后执行t1/t2,也可能是反过来,还可能是t1/t2交替执行,但是这段代码的执行结果也有可能是 x=0,y=0;。这就是在乱序执行的情况下会导致的一种结果,因为线程 t1 内部的两行代码之间不存在数据依赖,因此可以把 x=b 乱序到 a=1 之前;同时线程 t2 中的 y=a 也可以早于 t1 中的 a=1 执行。
解决线程安全问题
存在线程安全问题必须满足三个条件:
1.有共享变量
;
2.处在多线程
环境下;
3.共享变量有修改
操作。
通过上面一节可知,解决线程安全问题主要是要解决三个问题: 原子性、可见性、有序性。java为我么提供了多种方式来解决这三大问题,这里大致列一下几种常用的方式:
- volatile (慎用)
- synchronized
- Lock
Lock 是一个接口,有两个核心方法 lock() 和 unlock(),常用的实现类一般两种,ReentrantLock 和 ReentrantReadWriteLock。 - ThreadLocal
- java.util.concurrent.atomic 原子类
volatitle
关于不加 volatitle 的代码上面已经贴过了,会导致死循环。加上 volatitle 的执行结果就完全不一样了。
public class TestThread0_Visable {
private volatile static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) { i++; }
System.out.printf("********** Thread 跳出, i=%d **********\n", i);
}).start();
Thread.sleep(100);
flag = false;
System.out.printf("********** main thread 结束, i=%d **********\n", i);
}
}
********** main thread 结束, i=203552683 **********
********** Thread 跳出, i=203552683 **********
需要特别注意的是 volatile 并不能保证原子性,所以要慎用。
synchronized
关于synchronized 其实也没什么好说的,这里只提一下测试过程中遇到的一个小问题,看如下代码:
public class TestThread3_synchronized {
private static Integer num = 0;
private static final String str = "lock";
public static void main(String[] args) throws InterruptedException {
Long timeStart = System.currentTimeMillis();
System.out.printf("执行开始: %d\n", num);
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
/*synchronized (TestThread3_synchronized.class) {*/
/*synchronized (str) {*/
synchronized (num) {
num++;
}
}
System.out.println("减法执行完毕\n");
downLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
/*synchronized (TestThread3_synchronized.class) {*/
/*synchronized (str) {*/
synchronized (num) {
num--;
}
}
System.out.println("加法执行完毕");
downLatch.countDown();
}).start();
downLatch.await();
System.out.printf("执行结果: %d\n", num);
Long timeEnd = System.currentTimeMillis();
System.out.printf("执行耗时: %d 毫秒", timeEnd - timeStart);
}
}
其中用代码中的注释掉的 锁当前类 与锁一个 str 对象都没问题,结果都为零。但是,如果我们锁当前操作的对象 num .得到的结果却并不对。
如果变量的引用发生了改变,就会导致synchronized失效,然后其他线程就会进入原本没有结束的synchronized代码块。究其根本原因在于 对象在内存中的存储方式以及 synchronized 的原理:Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。java的编译器在遇到i++和i- -的时候会重新为变量运算分配一块内存空间,以存放原始的值,而在完成了赋值运算之后,将这块内存释放掉。
然后我们再换种写法
public class TestThread4_synchronized_2 {
private static Obj obj = new Obj();
public static void main(String[] args) throws InterruptedException {
System.out.printf("执行开始: %d\n", obj.i);
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (obj) {obj.i++;}
}
System.out.printf("减法执行完毕\n");
downLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (obj) {obj.i--;}
}
System.out.printf("加法执行完毕\n");
downLatch.countDown();
}).start();
downLatch.await();
System.out.printf("执行结果: %d\n", obj.i);
}
public static class Obj {
public int i = 0;
}
这样得到的结果就正确了。
Lock
lock是比synchronized 更轻量级的锁。关于lock知识点比较多,完全可以作为一个新的话题来讲了,这里就不多说了,只贴一个基础使用的代码。
public class TestThread5_lock {
private static Integer num = 0;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
System.out.printf("执行开始: %d\n", num);
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
lock.lock();
num++;
lock.unlock();
}
System.out.printf("减法执行完毕\n");
downLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
lock.lock();
num--;
lock.unlock();
}
System.out.printf("加法执行完毕\n");
downLatch.countDown();
}).start();
downLatch.await();
System.out.printf("执行结果: %d\n", num);
}
}
ThreadLocal
threadlocal而是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据。这里就不说了。
atomic 原子类
public class TestThread6_Atomic {
private static Integer num = 0;
private static AtomicInteger a = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
System.out.printf("执行开始: %d\n", num);
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num++;
a.getAndIncrement();
}
System.out.printf("减法执行完毕\n");
downLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num--;
a.getAndDecrement();
}
System.out.printf("加法执行完毕\n");
downLatch.countDown();
}).start();
downLatch.await();
System.out.printf("执行结果: %d\n", num);
System.out.printf("执行结果: %d\n", a.get());
}
}
参考文章:
https://zhuanlan.zhihu.com/p/51613784
https://www.yuque.com/yinjianwei/vyrvkf/hi3xiq
https://mp.weixin.qq.com/s/r_gtAdTVBKSm52To-2d4Ew
https://cloud.tencent.com/developer/article/1688593
推荐阅读:
https://juejin.im/post/6844903890224152584