赞
踩
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
为了直观地了解什么是原子性,我们看下下面哪些操作是原子性操作
int count = 0; // 1
count++; // 2
int a = count; // 3
除了语句1是原子操作,其它两个语句都不是原子性操作,下面我们来分析一下语句2
其实语句2在执行的时候,包含三个指令操作:
对于上面的三条指令来说,如果线程 A 在指令 1 执行完后做线程切换,线程 B 执行完3个指令,又切换回线程 A ,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。这也是经典的缓存一致性问题。这个能被多个线程访问的变量count称为共享变量。
注意:操作系统做任务切换,可以发生在任何一条CPU 指令执行完
为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序中语句的先后顺序,在多线程并发运行的程序中,这种优化可能会导致执行结果的变化。
例如,在单例模式中,如下:
public class Singleton { static Singleton instance; static Singleton getInstance() { // 返回实例 if (instance == null) { synchronized(Singleton.class) { // 同一时间只能有一个线程持有类Singleton的class对象 if (instance == null) instance = new Singleton(); // 创建实例 } } return instance; } }
创建实例的语句 instance = new Singleton()
未被编译器优化的指令执行顺序:
编译器优化后:
假如现在有A、B两个线程,线程 A 先执行 getInstance() 方法,执行完优化后的指令2时,发生了线程切换,线程B开始执行该方法,执行到第一次判断 instance==null 会发现 instance 不等于 null 了,所以直接返回 instance ,而此时的 instance 是没有初始化过的,与预期不符。现行的比较通用的做法就是采用静态内部类的方式来实现单例模式:
public class SingletonDemo {
private SingletonDemo() {
}
private static class SingletonDemoHandler {
// 静态内部类
private static SingletonDemo instance = new SingletonDemo(); // 静态成员变量,在这个内部类加载的时候,就被加入到<clinit>()方法中运行
}
public static SingletonDemo getInstance() {
return SingletonDemoHandler.instance; // 创建内部类实例,此时加载内部类到 JVM
}
}
指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
首先看一下 java 内存的结构:
线程1对共享变量的修改要被线程2及时看到的话,要经过如下步骤:
回到缓存不一致问题,也就是破坏了java语句原子性的问题。如果一个变量在多个CPU(或一个CPU的不同时间段)中都存在缓存,就可能存在缓存不一致的问题。为了解决缓存不一致性问题,通常来说在硬件层面有以下 2 种解决方法:
在早期的 CPU 中,通过在总线上加 LOCK# 锁可以解决缓存不一致问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就能够阻塞其他 CPU 对其他计算机部件(如内存)的访问,从而使得同一时刻只能有一个 CPU 使用这个变量的内存数据。比如上面一节的例子中,如果一个线程在执行 count++,在执行这段代码的过程中,总线上发出了 LOCK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能再从内存读取变量count的值,然后进行操作。这样就解决了缓存不一致的问题。
但是上面的方式导致用 LOCK# 锁住总线的期间,其他 CPU 无法访问内存,导致线程并发效率低下。
为了保证一定的并发效率,不使用总线加锁的方式,缓存一致性协议出现了。最出名的就是英特尔的 MESI 协议,MESI协议保证了每个缓存中使用的共享变量副本是一致的。它核心的思想是:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也可能存在该变量的副本,就会发出信号通知其他 CPU 将该变量的缓存置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
此种方法比锁总线的方法效率更高,当锁总线的方法更加通用。缓存一致性协议也是volatile的一部分底层实现保证。
即前一节可见性中介绍的内存模型,它是在软件侧面解决 java 并发问题的基础,它能够屏蔽各个硬件平台和操作系统的内存访问差异,实现让 java 程序在各种平台下都能达到一致的内存访问效果。
java 内存模型定义了程序中变量的访问规则,往大一点说,定义了程序执行的次序。注意,为了获得较好的执行性能, Java 内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。所以,Java内存模型的设计并没有直接解决三性问题,而是提供了解决的基础。
在java中,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
注意:在 32 位平台下,对 64 位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的 JDK 中,JVM 已经保证对 64 位数据的读取和赋值也是原子性操作了。
java 内存模型只保证了基本读取和常量赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized、Lock或CAS机制来实现。
对于可见性,Java提供了 volatile 关键字来保证。
当一个共享变量被 volatile 修饰时,它的值一旦被修改,就会被立即更新到主存,当其他线程要读取该值时,会直接去内存中读取新值,而不是在自己的私有工作空间读取旧值。未被volatile修饰的共享变量不能保证可见性,因为被修改之后,最新的值什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值。
另外,synchronized 和 Lock 也能够保证可见性,因为synchronized 和 Lock 能保证同一时刻只有一个线程获取锁,然后执行同步代码,所以不存在共享变量,也就不会有别的线程来修改已经被 synchronized 和 Lock 锁定的变量。在释放锁之前会将对变量的修改刷新到主存当中。
Volatile能够禁止指令重排序,但synchronized 和 Lock不会禁止指令的重排序,因为它们将线程编程了串行执行的,即时重排序也不会影响串行执行线程的执行结果,所以也就不禁止了。
另外,Java 内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
happens-before原则(先行发生原则):
start()
方法先行发生于此线程内的每个动作interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行finalize()
方法的开始前 4 条规则是比较重要的,后 4 条规则都是显而易见的,总结下来就是:
此部分内容来自马士兵教育在 b 站的 jvm 公开课,此部分和锁的实现较为相关
java对象的内存布局,即java对象在堆中存储时的内存布局。在maven中引入工具JOL(java object layout)后,可以使用System.out.println(ClassLayout.parseInstance(new Object()).toPrintable();
输出java对象的内存布局:
new一个对象,此对象的内存布局如下:
每个对象都拥有锁,都可以被独占,对象头中的 markword 是用来保存锁状态的,非常重要,会在锁升级部分中讲解markword具体保存什么数据
1、markword:8字节,记录锁这个对象的信息
2、class pointer:指向该对象的Class对象,markword和class pointer合起来就是对象头。默认情况下,JVM开启了指针压缩,此部分占4字节,若不开启指针压缩,此部分在64位机器上就是8字节。通过jvm参数-XX:+UseCompressedClassPointers可以设置。另一个参数叫做-XX:+UseCompressedOops意为普通对象指针压缩,如指向String常量的String类型引用变量就是这种指针,被压缩为4字节
3、instance data:成员变量的引用或基础变量直接存储
4、padding:使得整个对象占有的内存达到8字节的倍数,因为总线读内存的时候,按照8字节的倍数读,如果分开存储就会变慢,所以padding是用来提高效率用的
数组的内存布局:
此关键字用来锁定一个对象。纠正一个错误:代码是不会被锁定的,只能锁定对象
Object o = new Object(); synchronized(o){ // 执行这段代码的时候,对象o被此线程独占 } synchronized void methodName() { // 此方法执行过程中,this对象被锁定 } static synchronized void methodName() { // 此方法执行过程中,this对象的class对象被锁定 // 相当于写成: // synchronized(Main.class) }
具体实现过程:
1、代码层面:synchronized
2、字节码层面:在互斥代码区前面加 monitor enter ,在互斥代码区最后加 monitor exit
3、JVM层面:进行锁升级
4、汇编指令:lock cmpxchg
,利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status,所以很好做重入操作的判断。
JDK1.6 为了提升性能,减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁(new)、偏向锁、轻量级锁(自旋锁、无锁CAS)和重量级锁,它会随着多线程竞争变得激励而逐渐升级。锁降级是gc中的一个过程,就是没有任何线程访问这个对象,只有gc线程访问它,所以说降级没有任何意义,所以可以认为锁降级不存在。
在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。synchronized默认是一个非公平锁,所有挂起的线程会被放在等待队列中,这个队列默认是无序的。在hotspot中,不同的锁状态对应markword为:
图源:马士兵教育在b站的jvm公开课视频
在实际运行中,要获得锁状态,会首先检查最右两位“锁标志位”,然后检查偏向锁位,即可分出5种状态来。
上图中的分代年龄即gc回收需要使用的对象年龄记录。ps/po中的默认值为15,CMS中的默认值为6,分代年龄用4位表示,最大就是15,不能再更大了。
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:
先看一段代码,假如线程 1 先执行,线程 2 后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
这是一段很典型的代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,线程1不一定会被成功中断。在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
因为线程1和2在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。当线程 2 更改了 stop 变量的值之后,有可能在还没来得及写入主存中的时候,就发生了线程切换,线程 2 被迫阻塞,而线程1不知道线程 2 对 stop 变量的更改,会一直循环下去(假设是多核CPU)。
但是用 volatile 修饰之后,在工作内存中被修改的值会立即写入主存,且当线程 2 修改stop时,线程 1 的工作内存中缓存变量 stop 的缓存行被置为无效状态,线程 1 只能再次从内存读取变量 stop 的值,才能接着使用。
看例子:
public class Main {
volatile int inc = 0;
void increase() {
inc+&#
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。