上次我们提到了JVM为了安全推出的 双亲委派机制
那么双亲委派机制可能被打破吗?
可以,那么如何打破呢? ClassLoader.loadClass
方法定义了类加载的双亲委派机制,重写该方法即可跳出双亲委派。
历史上出现过几次双亲委派被破坏的案例
- JDK 1.2 之前,想自己实现类的加载必须重写 loanClass 方法。
- Thread.ContextClassLoader 中的线程上下文加载器,不是按照双亲委派机制运行的。
- 热启动、热部署等场景,每一个 WebApplication 都有自己的 ClassLoader,可以实现不同的 WebApplication 使用同一类库的不同版本。
双亲委派机制的一些局限性
当我们更加基础的框架需要用到应用层面的类的时候,只有当这个类是在我们当前框架使用的类加载器可以加载的情况下我们才能用到这些类。换句话说,我们不能使用当前类加载器的子加载器加载的类。这个限制就是双亲委派机制导致的,因为类加载请求的委派是单向的。
为了解决这个问题,引入了线程上下文类加载器。通过java.lang.Thread类的setContextClassLoader()设置当前线程的上下文类加载器(如果没有设置,默认会从父线程中继承,如果程序没有设置过,则默认是System类加载器)。有了线程上下文类加载器,应用程序就可以通过java.lang.Thread.setContextClassLoader()将应用程序使用的类加载器传递给使用更顶层类加载器的代码。
volatile
关键字
作用:
- 保证线程可见性
- 禁止指令重排序
其中线程可见性意为当某个线程中对内存做的读写操作,对其他线程是可见的。
保证线程可见性依赖于硬件层面的缓存一致性协议来实现
CPU 在访问内存前,先要进行缓存的访问。对于上面的CPU核来讲,其对缓存中数据的修改,需要保证运行在其他CPU核上的线程可见。这个数据一致性在硬件层面是由 缓存一致性协议 来保证的。
其中一种缓存一致性协议为 MESI
(Intel CPU 使用):
Modified
修改的Exclusive
排他的Shared
共享的Invalid
不合法的
MESI 标识上述四种状态。简单来说,当某一 CPU 修改了在缓存中的某一数据时,会将这个 缓存行
(CPU取数据时并不是一个一个地取的,而是按照缓存行一行一行地取。这是因为一种物理空间上的程序局部性原理,即CPU访问了某一块内存,其附近的数据也很有可能很快会用到。缓存行一般是 64 byte
,这个数值的设置和地址总线的宽度有关) 的状态标记为 Modofied
然后将该缓存的新值以及状态同步到下一级缓存。其他 CPU 上这个缓存行的状态会变为 Invalid
。
参考资料:【并发编程】MESI–CPU缓存一致性协议
现代CPU的数据一致性实现 = 缓存锁(MESI …) + 总线锁
位于同一缓存行的两个不同数据,被两个不同CPU锁定,会产生互相影响的伪共享问题。即这两个CPU其实并不是修改的同一个数据,但是他们位于一个缓存行,其中一个 CPU 修改了这条数据后仍需按照缓存一致性协议来通知其他的CPU,增加了额外的同步开销。
对于为共享问题,使用缓存行的对齐能够提高效率。一些成熟的框架中会引入类似如下代码来避免伪共享。
public class CacheLinePadding_Deprecated {
private static long COUNT = 1_0000_0000L;
private static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7;
}
/**
* T02比T01快很多, p1 - p7 占用 56 字节,x 占用 8字节,
* 导致一个数组中的两个 T 对象中的 x 不可能在同一个缓存行里
* 如此一来,一个CPU对缓存的修改不用按照缓存一致性协议通知另一个CPU
*/
private static class T extends Padding{
public volatile long x = 0L;
}
private static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (long i = 0; i < COUNT; i++)
arr[0].x = i;
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < COUNT; i++)
arr[1].x = i;
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
禁止指令重排序
首先要了解一下什么是指令重排序:
CPU 存在一个乱序执行的概念,也就是说,CPU 在执行指令时,并不会严格按照指令的顺序来依次执行。
-
比如 CPU 在执行一条读数据指令时,需要 100 个机器周期 (cycle)。而其下一条运算指令只需要 1 cycle,并且不依赖前一条指令的返回,CPU 完全没必要傻等着读数据。
-
多个写操作也可以进行合并:
WCBuffer
CPU 对缓存的写首先写入 WCBuffer 中,这个 buffer 速度非常快(甚至比L1还快),但是只有 4 个位置。当写满四次之后一次更新到 L2;
参考资料:现代cpu的合并写技术对程序的影响
CPU 乱序执行的证明
参考资料:Memory Reordering Caught in the Act
public class Disorder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(() -> {
// 由于线程 one 先启动,下面这句话让它等一等线程 two . 读者可以根据自己电脑的实际性能适当调整等待时间.
shortWait(100000);
a = 1;
x = b;
});
Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次" + "(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
写操作的合并
这个程序中一次写六个 byte 的速度要远低于 一次写三个 byte(加上byte b,正好四个字节);
public final class WriteCombining {
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 void main(final String[] args) {
for (int i = 1; i <= 3; i++) {
System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo());
}
}
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;
}
}
volatile
关键字如何做到禁止指令重排序
硬件内存屏障 - X86
- sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
- lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
- mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
原子指令,如x86上的 lock …
指令是一个Full Barrier
,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks
通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序
JVM级别如何规范(JSR133)
LoadLoad
屏障:
Load1;
LoadLoadBarrier;
Load2,
对于这样的语句,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore
屏障:
Store1;
StoreStoreBarrier;
Store2;
对于这样的语句,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore
屏障:
Load1;
LoadStore;
Store2;
对于这样的语句,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad
屏障:
Store1;
StoreLoad;
Load2;
对于这样的语句,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
volatile
的实现细节
如果是写操作,它会导致其他CPU中对应的缓存行无效。
一个处理器的缓存回写到内存会导致其他处理器的缓存失效。
处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
-
字节码层面
ACC_VOLATILE
-
JVM层面
volatile
内存区的读写 都加屏障StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
volatile 读操作
LoadStoreBarrier
LoadLoadBarrier
-
OS和硬件层面
hsdis - HotSpot Dis Assembler
windows lock 指令实现 | MESI实现参考资料:[volatile与lock前缀指令](
总线风暴
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。