JMM和并发的三大问题
1、JMM
1.1、什么是JMM?
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,它并不真实存在,他描述的是一组规则或规范,是一个抽象的感念。
1.2、JMM主要做什么?
首先要知道所有的变量都是存放在主内存中的,Java中每创建一个线程,JMM都会为其创建一个工作内存,这个内存区域是私有的,主要就是在用到主内存的变量时,先将主内存中的变量复制一个出来放到工作内存中,然后进行一系列的操作,操作完成之后和主内存中对应的变量做一个同步,详情看下图:
1.3、JMM区域的大致位置
这个位置不是很能确定,但大致可对应JVM的如下区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈 以及本地方法栈。
1.4、为什么要有JMM
我们可以简单的理解为JMM就相当于我们的线程栈,当然,不完全是哦,但是它两都有一个特性,那就是线程私有的,因为只有这样,线程才能安全的执行,如果说不是线程私有的,万一现在有两个线程同时对一个变量进行操作,一个要读取,一个要修改,那么不确定读取的先执行还是修改的先执行,所以,就会产生错乱,这个时候就会出现线程安全问题,所以就有了JMM。
2、数据同步操作
2.1、数据同步的八大原子操作
1、lock(锁定):作用于主内存中,将主内存中的变量变成一个独占的状态,意思就是当线程A访问变量B的时候,其它的线程不能同时访问。
2、unlock(解锁):作用于主内存中,将一个处于锁定状态的变量释放出来,释放之后才能被其它线程所访问。
3、read(读取):将主内存中的一个变量复制一份放到总线中,以便于后续的load动作加载到工作内存中。
4、load(载入):作用于工作内存中,他将read操作从主内存中得到的变量值副本经过load操作放到线程私有的工作内存中。
5、use(使用):作用于工作内存,将load操作载入到工作内存的某一变量的值传递给执行引擎。
6、assign(赋值):将执行引擎的一个值赋给线程私有的工作内存的某一变量。
7、store(存储):将线程私有的工作内存中的值传递到主内存中,以便后续的write操作。
8、write(写入):将store操作的值赋值给主内存的变量,相当于一个同步的操作。
注意:Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
2.2、同步规则分析
1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
3、并发编程
3.1、Java并发编程的三大问题
1、可见性:可见性是当一个变量被其中一个线程修改了,其它使用该变量的线程都会看到这个线程被修改了,将会重新从主内存中去load这个变量。
2、原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程所中断影响。
小插曲:在Java中,八大数据类型有一个需要注意的点,对于32位机器来说,每一个原子性的读取位数为32位,八大基本数据类型中比如long和double数据类型占八个字节,所以,32位系统会读取两次,那么这样就有可能出现一种当两个线程同时使用这个变量时,就会出现读到的不是最新的数据这种,64位机器的话就基本不会出现。
3、有序性:对于单线程来说,程序的执行是有序性的,也就是说程序的执行是按照我们所写的顺序执行的,但是在多线程中就不一定了,因为程序编译时会出现一种叫做指令重排的过程,指令重排之后就不一定是我们写的代码顺序了,但是指令重排的同时必须要保证执行的结果与原始的结果一致。
3.2、Java并发编程的三大问题解决
原子性问题:除了JVM本身为我们提供的基本类型操作是原子类型外,可以通过synchronized和Lock实现原子性,因为synchronized和Lock可以保证同一时间内只能有一个线程访问此代码块。
可见性问题:volatile关键字保证可见性。当一个变量被volatile修饰时,它如果被一个其它线程修改了,则会去及时通知其它使用该变量的线程去主内存里面去重新加载取值,synchronized和Lock实现原子性,因为synchronized和Lock可以保证同一时间内只能有一个线程访问此共享资源,并且在锁释放时将新的值赋值到主内存。
有序性问题:volatile关键字可以保证代码的有序性,synchronized和Lock也可以,因为他两在修饰共享资源时是单线程执行,所以是代码保持有序性。
10、辅助知识
10.1、浅谈volatile
10.1.1、volatile的概念
private volatile static Boolean myBool = true;
Thread threadA = new Thread();
Thread threadB = new Thread();
概念:由于java各个线程之间是不具备可见性的,所以如果两个线程同时使用一个变量的时候,如果其中一个线程修改了变量,另一个线程是无法看到的,为了实现这种可见性,保证程序的正确性,volatile会提供一个同步通知机制,当变量被一个线程修改时,如果这个变量加了volatile关键字,那么这个变量被修改之后会主动通知到使用自己的所有线程,但是volatile不会加锁,所以被称为是java里面的一个轻量级锁,与synchronized区别开来。
注意点:其实不加volatile关键字的话,被引用的变量终有一刻也会被看到,只不过这个时间无法预料,因为这中间可能存在缓存行失效,上下文切换等等操作,会使CPU去重新加载这个变量,从而发现改变,所以,加了volatile只是保证了变量被修改时能及时通知到而已,但是一个线程中如果一直执行空循环就不一定了,因为空循环的执行时间很快,在CPU那里的优先级是特别高的,所以这种情况下就不容易看见主内存中的变量被其它线程改变,例如下面代码:
public class Jmm03_CodeVisibility {
private static boolean initFlag = false;
private volatile static int counter = 0;
public static void refresh(){
System.out.println("refresh data.......");
initFlag = true;
System.out.println("refresh data success.......");
}
public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){
// 我这里面随便写上一行代码,都有可能导致缓存行失效或者线程的上下文切换,这个时候CPU就要重新去主内存中去加载数据,加载的时候发现initFlag变量被改变了,然后就会退出循环了
//System.out.println("runing");
//counter++;
}
System.out.println("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
}
10.1.2、volatile内存语义
1、volatile修饰的变量如果被改变将会被所有所引用此变量的线程所看见。
2、禁止指令重排序优化,主要是通过内存屏障。
10.1.3、内存语义的实现
volatile会对我们的代码重排序加上一定的限制规则,例如下面这个是JMM针对编译器制定的volatile重排序规则表。
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
上面的这些规则呢,主要就是防止在多线程的环境下,指令重排给我们带来的不一定的结果,自己理解着记忆吧。
比如说:
1、当第一个操作为 volatile读 时,第二个操作无论是什么都不可以重排,要保证第一个读操作的正确性。
2、当第二个操作为 volatile写 时,第一个操作无论是什么都不可以重排,要保证第二个写操作的正确性。
10.1.4、JMM的保守策略
1、在每个volatile写操作的前面插入一个StoreStore屏障。
2、在每个volatile写操作的后面插入一个StoreLoad屏障。
3、在每个volatile读操作的后面插入一个LoadLoad屏障。
4、在每个volatile读操作的后面插入一个LoadStore屏障。
如果不知道屏障的请参考这篇文章
10.2、浅谈synchronized
概念:synchronized可以保障一组操作的原子性,
就比如下面代码:
下面的代码,我们new了10个线程,然后每个线程都对成员变量 counter 自加1,由于 counter ++; 操作不算是一个原子操作,所以在线程的上下文切换时就有可能造成数据的丢失或者自加的覆盖,所以最后的结果不一定是 10000 ,所以我们加上了 synchronized ,因为它可以保证 counter ++; 这个操作为原子操作。
public class Jmm04_CodeAtomic {
private volatile static int counter = 0;
static Object object = new Object();
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
// synchronized 修饰这个代码块的话,就可以将这个代码快里面的代码置为一组操作
synchronized (object){
//这里的 counter++; 操作分三步- 读,自加,写回
counter++;
}
}
});
thread.start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
}