回顾
Linking三步
- Verification:验证文件是否符合jvm规定,class文件的magic number 是否是 CA FE BA BE,如果不是则不进行下一步
- Preparation:给静态成员变量赋默认值
- Resolution:解析。将类,方法,属性等符号引用解析为直接引用,将常量池中的各种符号引用解析为指针,偏移量等内存地址的直接引用。loadClass(String name, boolean resolve)
Initializing:初始化代码,给静态成员变量赋初始值
class: load->默认值->初始值
new Object: new -> 申请内存 -> 默认值 -> 初始值
DCL 单例的volatile
DCL 也叫作 Double Check Lock。
public class DCLSigleton {
private static volatile DCLSigleton INSTANCE;
private DCLSigleton() {
}
public static DCLSigleton getINSTANCE(){
if (INSTANCE == null){
synchronized (DCLSigleton.class){
if (INSTANCE == null){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new DCLSigleton();
}
}
}
return INSTANCE;
}
}
- 是否需要加volatile?
是需要的。
如果不加volatile会发生一个情况:第一个线程拿到锁,检查完了其他线程并没有初始化然后加锁,上锁之后对这个INSTANCE进行初始化;初始化到一半的时候(new了这个对象,并且申请了内存,但成员变量还只是拿到了默认值,这个情况下,这时候INSTANCE已经指向内存了,所以这个INSTANCE不等于空了)此时另外一个线程来了,他在执行if(INSTANCE == null)这句代码的时候,判断已经是false,但是实际上 INSTANCE其实还只是半初始化状态,但是第二个线程就已经开始使用这个默认值了,所以避免这个问题就需要加上volatile。
- 指令重排序
正常的汇编执行逻辑:
- 给INSTANCE实例分配内存
- 初始化INSTANCE的构造器
- 给INSTANCE对象指向分配的内存空间(此时INSTANCE就非null)
但是JVM有时为了优化指令,提高运行效率,允许指令重排,所以导致可能执行的顺序变为:
- 给INSTANCE实例分配内存
- 给INSTANCE对象指向分配的内存空间(此时INSTANCE就非null)
- 初始化INSTANCE的构造器
tips: 虽然synchronized虽然保证了线程的原子性(synchronized块中的语句要么全部执行,要么一条也不执行),但单条语句编译后形成的指令并不是一个原子操作(可能该条语句的部分指令未被执行,就被切换到另一个线程了)因此需要禁止指令重排序优化,即使用volatile变量。
Java内存模型 JMM
Java Memory Model。
数据一致性
如果有一个数在内存里,这个数会被load到L3缓存上,L2和L1是内部的,这时候会产生一种情况,L3或主存里的数会load进不同的cpu内部,这个时候如果把cpu1的x改成2,cpu里的x是1,那么会产生数据不一致的问题,如何解决数据不一致的问题?
-
Cache line 缓存行。
缓存行是当我们要把内存里的某些数据放到我们CPU自己缓存里,不只把这个数据放进去。假如用到了int类型12,他只有4字节,但是读取缓存的时候不只是把这4字节读取到缓存里,而是把后面的60字节内容也一并读取进去,这个叫做 cache line缓存行。
可以使用缓存行对齐来提高性能,但是牺牲了空间。伪共享: 位于同一缓存行的两个不同数据,被两个不同的cpu锁定,产生互相影响的伪共享问题。
缓存行越大,局部性控件效率越高,但读取时间慢,缓存行越小,局部性空间效率低,但读取速度快,目前是64字节。
-
总线锁
总线锁会锁住总线,导致其他cpu无法访问内存中的其他地址,导致效率较低,老的cpu会使用这种方式。 -
Cache一致性协议
MSI,MESI,MOSI等都是一致性协议。英特尔CPU使用MESI协议。下面拿MESI举例。
cpu每个cache line标记了四种状态。- 更改过标记成Modified
- 如果是独享标记成Exclusive
- 如果这个内容我读取的时候别人也在读取标记成Shared
- 如果这个内容在我读取的时候被别的cpu改过来就标记成invalid,这时候说明我读取的数据已经是无效的了
但是某些跨越多个缓存行的数据或者无法被缓存的数据仍然需要使用总线锁。现在cpu数据的一致性实现 = 缓存锁(MESI等各种协议) + 总线锁
乱序问题
CPU为了提高指令执行效率,会在一条指令执行过程中(比如去内存读取数据,比cpu慢100倍起步)会去同时执行另一条指令,前提是两个指令之间没有依赖关系。
- 读指令乱序执行比较好理解,就是从内存里读取数据因为比较慢,所以同时执行其他的指令。
- 写指令也有可能出现乱序的问题,cpu在L1和cpu之间还有一个缓存叫 WCBuffer合并写写操作也可以合并。
合并写就是说cpu在给某个数进行计算的时候,需要把这个结果写到主存里,写回到主存里的时候,有一个L1和L2, CPU计算这个结果之后,会把结果写到L1里,假如L1里面没有这个值,缓存没有命中,他会写到L2里,但是写L2过程中,由于L2过程中速度慢,所以在写的过程中如果这个数后续的一些指令也改变了这个值,他就会把这些执行合并到一起,扔到合并缓存里,最终计算扔到L2里。
合并写有一个细节,这个CPU会把其中的一些指令写到一个合并写缓存里,叫WCBuffer,这个缓存比L1速度还要快,一般这个buffer只有4个字节位置。
这里做个实验,有两个循环,一个循环是直接在循环里改变了6个位置,第二个循环里又分了两个循环,第一个修改其中三个,另外一个修改其中另外三个位置。
public class WriteCombing {
private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS -1;
private static final byte[] arrayA = new byte[ITEMS];
private static final byte[] arrayB = new byte[ITEMS];
private static final byte[] arrayC = new byte[ITEMS];
private static final byte[] arrayD = new byte[ITEMS];
private static final byte[] arrayE = new byte[ITEMS];
private static final byte[] arrayF = new byte[ITEMS];
public static long runCaseOne(){
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0){
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
public static long runCaseTwo(){
long start = System.nanoTime();
int i = ITERATIONS;
// 第一次循环
while (--i != 0){
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
}
i = ITERATIONS;
while (-- i != 0){
int slot = i & MASK;
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
public static void main(String[] args) {
for (int i = 0 ; i < 3 ; i++){
System.out.println("case one :" + runCaseOne());
System.out.println("case two :" + runCaseTwo());
System.out.println("==========================");
}
}
}
证明乱序
美团的一个小代码
public class Discover {
private static int a = 0 ,b = 0;
private static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
// 死循环
for (int i = 0;;i++){
x = 0 ; y = 0;
a = 0 ; b = 0;
// 第一个线程 执行 a = 1 ; x = b;
Thread one = new Thread(()->{
// 由于 one 线程先执行,让其等待线程other
shortWait(100000);
a = 1;
x = b;
});
// 第二个线程执行 b = 1; y = a;
Thread other = new Thread(()->{
shortWait(100000);
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第"+ i +"次 (" + x +","+ y +")";
//如果是顺序执行,则不可能出现 x = 0 , y = 0 的情况,一定会先执行 a = 1 或者 b = 1
if (x== 0 && y == 0){
System.out.println(result);
break;
}else{
//continue
}
}
}
public static void shortWait(long time){
long start = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while (start+time >= end);
}
}
如何保证特定情况下不乱序
-
volatile保证了有序性
-
硬件层面
X86CPU内存屏障- sfence :在sfence指令前的写操作当必须在sfence指令后的写操作前完成
- lfence :在lfence指令前的读操作当必须在lfence指令后的读操作前完成
- mfence:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成
保证有序性的硬件层面的指令,汇编指令CPU会编辑,第一个是加锁,但是为了提高效率,很多CPU会做同一件事就是加内存屏障,这个就要做CPU级别的内存屏障,不同的CPU内存屏障指令是不一样的。
-
JVM 级别的规范(JSR133)
JVM 本身就是软件层级的,jvm只是一些规范,jvm都有自己的实现。-
LoadLoad屏障
对于load1;LoadLoad;load2这样的语句,在load2以及后续的读取操作要读取数据访问前,保证load1要读取的数据被读取完毕。 -
StoreStore屏障
对于store1;StoreStore;store2这样的语句,在store2以及后续写入操作前,保证store1的写入操作对其他处理器可见。 -
LoadStore屏障
对于load1;LoadStore;store1这样的语句,在store1以及后续写入操作前,保证load1的读取数据要被读取完毕。 -
StoreLoad全能屏障
对于store1;StoreLoad;load1这样的语句,在load1以及后续读取操作前,保证store1的写入操作对其他处理器可见。
-
Volatile实现细节
-
字节码层面
ACC_VOLATILE -
JVM层面
volatile 在内存读和写操作前后都有两个屏障,写操作前面有一个 StoreStore屏障,后面有一个StroeLoad全能屏障,这两个屏障不能调换顺序,所以是顺序的;在读操作前面有一个LoadLoad屏障,后面有一个LoadStore屏障,这两个屏障也无法调换顺序,所以也是顺序的,不会产生乱序问题。 -
os和硬件层面
可以使用hsdis-HotSpot Dis Assembler对HotSpot进行反汇编,就是把虚拟机编译好的字节码再进行一次反汇编。
在Windows上使用lock指令去实现的,但是在Linux上是上面一个屏障,下面一个屏障和最后一条lock指令,中间的才是volatile区域。
Synchronized实现细节
-
字节码层面
-
synchronized同步块时候
monitorenter和monitorexit两个指令。monitorenter为进入同步块,monitorexit为退出同步块。图中有两个monitorexit是因为其中有一个为异常exit。为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁 -
synchronized作用于方法上
生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。
-
-
JVM层面
C/C++ 调用了操作系统提供的同步机制 -
os和硬件层面
硬件层面基本上是lock指令。如果要在synchronized某一块内存上加个数,把i的值从0变成1,从0变成1 的过程中如果放在cpu里取执行,可能有好几条指令或者不能同步,所以用lock指令。cmpxchg前面如果加了一个lock的话指的是后面的指令执行的过程当中这块区域锁定,只有我这条指令可以修改,其他指令无法做修改
补充一: java并发内存模型
补充二:happens-before原则(JVM规定重排序必须遵守的规则JLS17.4.5)
- 程序次序规则: 同一个线程内,按照代码出现的顺序,前面的代码先于后面的代码,准确来说是控制流程的熟悉怒,因为要考虑到分支和循环结构
- 管程锁定规则:一个unlock操作先于发生于后面(时间上)对同一个锁的lock操作
- volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读取操作
- 线程启动规则:Thread的start()方法先行发生于这个线程的每一个操作
- 线程终止规则:线程的所有操作都先行于线程的种植检测,可以通过Thread.join()方法结束,Thread.isAlive()的返回值等方法检测线程的终止
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测中断事件的发生,可以通过Thread.interrupt()方法检测线程是否中断
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
- 传递性:如果操作A先行于操作B,操作B先行于操作C,则操作A也是先行于操作C
评论区