大家好,我是小黑,一个在互联网得过且过的农民工。
上一期给大家分享了对于 Java 中线程相干的一些基础知识。在对于线程终止的例子中,第一个办法讲到要想终止一个线程,能够应用标记位的办法,咱们再来回顾一下代码。
class MyRunnable implements Runnable {
// volatile 关键字,保障主线程批改后以后线程可能看到被改后的值(可见性)private volatile boolean exit = false;
@Override
public void run() {while (!exit) { // 循环判断标识位,是否须要退出
System.out.println("这是我自定义的线程");
}
}
public void setExit(boolean exit) {this.exit = exit;}
}
public class ThreadDemo {public static void main(String[] args) {MyRunnable runnable = new MyRunnable();
new Thread(runnable).start();
runnable.setExit(true); // 批改标记位,退出线程
}
}
在这个代码中,标记位 exit 字段在申明时应用了 volatile 关机字润饰,目标是为了保障在另外一个线程批改后以后线程可能感知到变动,那么这个关键字到底做了些什么呢?这一期咱们来具体聊一聊。
在开始讲 volatile 关键字之前,须要先和大家聊一聊计算机的内存模型这个玩意儿。
计算机的内存模型
所谓内存模型,英文形容是 Memory Model,这玩意儿是一个比拟底层的货色,它是与计算机硬件无关的一个概念。
咱们都晓得,计算机在执行程序的时候,最终是一条条的指令在 CPU 中执行,在执行过程中往往会存在数据的传递。而数据是寄存在主内存上的,对,就是你那个内存条。
在刚开始 CPU 的的执行速度还不够快的时候并没有什么问题,但随着 CPU 技术的一直倒退,CPU 计算的速度越来越快,然而呢,从主内存上读取和写入数据的速度有点拉胯,跟不上呀,这就导致 CPU 每次操作主内存都要花费很多的等待时间。
技术总是要往前倒退的,不能因为内存读写慢 CPU 就不倒退了吧,也不能让主内存的读写速度成为瓶颈。
想必这里大家也应该想到了,就是在 CPU 和主内存之间加一个高速缓存,将须要的数据在这个高速缓存上复制一份,而这个高速缓存的特点就是读写很快,而后定期的将缓存中的数据和主内存同步。
到这里问题就解决了吗?too young,too simple 啊,这种构造在但线程的状况下是没有问题的,随着计算机能力一直晋升,开始反对多线程了,并且 CPU 牛逼到反对多核,到当初的 4 核 8 核 16 核,在这种状况下是会存在一些问题的,咱们来剖析一下。
单核多线程状况:多个线程同时拜访一个共享数据,CPU 将数据从主内存加载到高速缓存中,多个线程会拜访高速缓存中的同一个地址,这样即便在线程切换时,缓存数据也不会生效,因为在单核 CPU 同一时间只能有一个线程在执行,所以也不会有数据拜访的抵触。
多核多线程状况:每个 CPU 内核都会复制一份数据到本人的高速缓存,这样的话在不同内核上的两个线程是并行的,这样就会导致两个内核各自缓存的数据产生不统一。这个问题就叫做 缓存一致性问题。
除了下面说到的缓存一致性问题,计算机为了使 CPU 的算力可能被充分利用,会对输出的指令进行乱序解决,叫做 处理器优化 。很多的编程语言为了进步执行效率,也会对代码的执行程序从新排序,比方咱们 Java 虚拟机的即时编译器(JIT) 也会做,这个动作叫做 指令重排。
int a = 1;
int b = 2;
int c = a + b;
int d = a - b;
比方咱们写的这段代码,第三行和第四行的执行程序就有可能产生扭转,这在单线程中并没有问题,然而在多线程状况下,会产生和咱们预期不一样的后果。
其实下面提出的缓存一致性问题,处理器优化,指令重排就对应咱们并发编程中的 可见性问题,原子性问题,有序性问题。带着这些问题,咱们再来看看,在 Java 中是如何来解决的。
因为存在这些问题,那么必定要有一种机制来解决。这种解决的机制就是 内存模型。
内存模型定义了一个标准,用来保障共享内存的可见性,有序性,原子性。内存模型是怎么解决的呢?次要采取两种形式:限度处理器优化 和内存屏障。这里咱们先不深究底层原理。
JMM
从后面咱们晓得内存模型是一个标准,用来解决并发状况下的一些问题。不同的编程语言对于这个标准都有对应的实现。那么 JMM(Java Memory Model)就是 Java 语言对于这一标准的具体实现。
那么 JMM 具体是如何解决这写问题的呢?咱们先来看上面这张图。
内存可见性问题
咱们一个一个问题来看,首先,如何解决 可见性问题?
如上图所示,在 JMM 中,一个线程对于一个数据的操作,分成了 6 个步骤。
别离是:read,load,use,assign,write,store.
如果说这个变量在申明时,没有应用 volatile 关键字,那么两个线程是各自复制一份到工作内存,线程 B 将 flag 赋值为 true,线程 A 是不可见的。
那么要想线程 A 可见,就须要在申明 flag 这个变量时,加上 volatile 关键字。那么加了关键字之后 JMM 是怎么做的呢?这里要分读和写两个状况。
- 线程在读取一个 volatile 变量时,JMM 会把工作内存中的该变量置为有效,从新从主内存中读取;
- 线程在写一个 volatile 变量时,会立即将工作内存中的值刷新到主内存中。
也就是说,对于 volatile 关键字润饰的变量,在 read,load,use 操作必须是一起执行的;assign,write,store 操作时一起执行。
通过这样的形式,就可能解决内存可见性的问题。
指令重排
而指令重排这个问题,对于编译器来说,只有该对象申明为 volatile 的,那么就不会对它进行指令重排的优化。
而 volatile 禁止指令重排的这种规定是合乎一个叫做 happens-before 的规定。
happens-before 除了在 volatile 变量规定外,还有一些其余规定。
程序秩序规定:在一个线程内一段代码的执行后果是有序的。就是还会指令重排,然而轻易它怎么排,后果是依照咱们代码的程序生成的不会变。
管程锁定规定:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作后果!(管程是一种通用的同步原语,synchronized 就是管程的实现)
volatile 变量规定:就是如果一个线程先去写一个 volatile 变量,而后一个线程去读这个变量,那么这个写操作的后果肯定对读的这个线程可见。
线程启动规定:在主线程 A 执行过程中,启动子线程 B,那么线程 A 在启动子线程 B 之前对共享变量的批改后果对线程 B 可见。
线程终止规定:在主线程 A 执行过程中,子线程 B 终止,那么线程 B 在终止之前对共享变量的批改后果在线程 A 中可见。也称线程 join()规定。
线程中断规定 :对线程 interrupt() 办法的调用后行产生于被中断线程代码检测到中断事件的产生,能够通过 Thread.interrupted()检测到是否产生中断。
传递性规定:happens-before 准则具备传递性,即 hb(A, B),hb(B, C),那么 hb(A, C)。
对象终结规定:一个对象的初始化的实现,也就是构造函数执行的完结肯定 happens-before 它的 finalize()办法。
竞态条件
到这里,大家是不是感觉问题曾经都解决了?emmm,咱们来看上面这个场景:
假如上图中的线程 A 和线程 B 执行在两个 CPU 外围上,是并行执行的,它们一起读取到 i 的值等于 0,而后各自加 1,而后一起往主内存写。如果线程 A 和线程 B 是有先后顺序执行的,i 的值最初应该是等于 2 才对,然而并行状况下是有可能同时操作的,最初写回到主内存中的值只被减少了一次。
这就好比你的银行卡收到了两笔 100 块的转账,然而账户上只多了 100 块。
对于这种问题通过 volatile 是无奈解决的,volatile 不会保障该变量操作的原子性。那咱们应该怎么解决呢,就须要应用 synchronized 对这个操作加锁,保障同一时刻只能有一个线程进行操作。
总结
因为 CPU 和内存之间存在着高速缓存,在多线程并发状况下,可能会存在缓存一致性问题;而 CPU 对于输出的指令会做一些处理器优化,一些高级语言的编译器也会做指令重排。因为这些问题,会导致咱们在并发状况下存在内存可见性问题,有序性问题,而 JMM 就是 Java 中为了解决这些问题而呈现的。通过 volatile 关键字能够保障内存可见性,并且会禁止指令重排。然而 volatile 只能保障操作的有序性,无奈保障操作的原子性,所以,为了平安,咱们对于共享变量的并发解决要进行加锁。
好的,明天的内容就到这里,咱们下期再见。
喜爱的敌人能够关注小黑的公众号,定期干货分享。