前言
本节开始进行到线程的一些知识,从jvm角度的去看如何处理线程。
硬件的缓存一致性
在了解java 内存模型之前,先了解一下物理机的解决并发的方式。jvm中的并发问题场景有很多与物理机的并发场景相似,所以参考物理机解决并发的问题有很大意义。
首先,现在pc中几乎都是多核处理器,很少有单核处理器了,所以运行处理都是多线程并发执行的。
其次,得了解一下内存的读写效率,作为存储器,毫无疑问cpu中的寄存器是最高的,但也是空间最小的,内存虽然大,但其读写效率与cpu的计算效率相差几个级别。所以cpu不能直接与内存交互,于是就有了高速缓存,介于cpu与内存之间,存储容量大于寄存器小于内存,读写效率也是一样低于寄存器大于内存。
那每个cpu有了缓存,而主内存就一块自然就存在并发问题,所以为了保持从内存中读取的数据一致,就产生了缓存一致性的协议,所有cpu在工作时遵循这个协议。
除了高速缓存,处理器还会乱序执行来最大化利用处理器的内部计算单元。所谓的乱序执行就是输入代码的执行顺序打乱,但保证其结果和代码顺序执行的结果一致,这点jvm参考后也有了指令重排这一过程。
java内存模型
首先java的内存模型只是理论模型,不是现实的物理模型。定义java内存模型的目的定义程序中各种变量的访问规则,让jvm有序的高效的工作。
如图,java的内存模型参考上图硬件缓存模型。主内存类比于内存一下存储着所有变量,工作内存就是各个线程独有的类似高速缓存一样,将所需的变量从主内存copy到自己的工作内存中。
save,load指令为一些内存交互的操作指令。
内存操作指令
java内存模型中定义了八种操作指令
- lock:作用于主内存的变量,它把一个变量标识为一条线程独占
- unlock: 作用于主内存的变量,它把一个被lock标识的变量释放出来,释放后的变量才可以被其他线程锁定
- read:作用于主内存的变量,它把一个变量的值从主内存传输打工作内存中,方便之后的load操作
- load:作用于工作内存的变量,它把read读取的变量值放入到工作内存的变量副本中
- use:作用于工作内存的变量,它把工作内存中变量的值传给执行引擎,虚拟机需要使用一个变量值的时候就会调用此字节码指令
- assign:作用于工作内存的变量,它把执行引擎接收到的值赋值给变量,虚拟机给变量赋值会调用此字节码指令
- store:作用于工作内存的变量,它把工作内存中的值传递给主内存,方便之后的write指令操作。
- write:作用于主内存的变量,它把store传来的变量值放入主内存中的变量中。
java要求read,load 以及store,write是必须顺序执行的命令,但可以不必连续。比如read,load中可以插入其他命令,如read a,read b, load a ,load b 这就是典型的顺序非连续指令。
当然,对于操作指令的要求还不止以上的这些,对于内存操作指令有一整套完整的严格要求。
- 不允许,read,load,write,store指令单独出现,即不允许出现一个变量从主内存读,但工作内存不接受或者工作内存发起回写,主内存拒绝接受。
- 不允许线程丢弃最新的assign操作,即变量在工作内存改变后必须同步到主内存的变量中
- 不允许线程没有发生任务号assign操作,就将其工作内存的值回写到主内存
- 新的变量只能产生与主内存,不允许工作内存使用未初始化的变量,即未被load或者assign操作过的变量,即执行user或者store指令时,必须先执行assign和load操作
- 一个变量同一时刻只能被一个线程lock,但lock操作可被同一线程执行多次,执行多次lock后,需要执行相同次数的unlock操作后才会解锁
- 变量没有被lock,就不允许对变量进行unlock操作,也不允许unlock其他线程lock的变量
- 对变量进行unlock之前,必须先同步回主内存中,即必须先执行store,write指令。
Volatile修饰符
首先了解一下volatiled的语义:
- 保证变量对所有线程的可见性
- 禁止指令重排序
首先来谈谈确保volatile所有线程的可见性,
voltaile修饰的变量值变更后,其他线程可立即获得变更后的新值。这是普通线程做不到的,普通线程要获得新值的过程是,等待修改值的线程store,以及wrte后,再去主内存进入read,load才能拿取到新值。
但这并不意味着volatile是并发安全的,因为缺乏原子性。最经典的案例就是多线程自增案例
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}
这段代码启动20个线程,每个线程对race值进行10000次自增操作,会发现无论运行多少次,结果都会不同而且总小于200000。其原因就出现在race++这段代码里
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I
8: return
LineNumberTable:
line 14: 0
line 15: 8
将race++反编译成字节码,getstatic这时候volatile确保取到最新的race值,再进行+1操作的两个字节码指令的时候,iconst_1,iadd,而被volatile修饰的race已被其他线程变更,putstatic操作的值已经是过期的旧值。
指令重排序
指令重排序本意是硬件,或者操作系统或编辑器对程序的优化,指令重排的前提条件是没有指令重排序的运行结果与进行过指令重排序的后的运行结果必须保持一致,这在单的cpu下确实是可行的。但是多处理机的多线程下并发造成的问题就无法确保指令重排序还能保持结果一致。其实现原理就是通过写屏障,在禁止排序的指令集前添加写屏障,执行引擎读到写屏障就不会对写屏障包裹着的指令进行排序。
总结
本文介绍了java的内存模型以及volatile的修饰符的含义