赞
踩
/** * 类说明:伪共享 */ public class FalseSharing implements Runnable { public final static int NUM_THREADS = Runtime.getRuntime().availableProcessors(); public final static long ITERATIONS = 500L * 1000L * 1000L; private final int arrayIndex; /*数组大小和CPU数相同*/ // private static VolatileLong[] longs = new VolatileLong[NUM_THREADS]; // private static VolatileLongPadding[] longs = new VolatileLongPadding[NUM_THREADS]; private static VolatileLongAnno[] longs = new VolatileLongAnno[NUM_THREADS]; static { /*将数组初始化*/ for (int i = 0; i < longs.length; i++) { longs[i] = new VolatileLongAnno(); } } public FalseSharing(final int arrayIndex) { this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception { final long start = System.nanoTime(); runTest(); System.out.println("duration = " + (System.nanoTime() - start)); } private static void runTest() throws InterruptedException { /*创建和CPU数相同的线程*/ Thread[] threads = new Thread[NUM_THREADS]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new FalseSharing(i)); } for (Thread t : threads) { t.start(); } /*等待所有线程执行完成*/ for (Thread t : threads) { t.join(); } } /*访问数组*/ @Override public void run() { long i = ITERATIONS + 1; while (0 != --i) { longs[arrayIndex].value = i; } } public final static class VolatileLong { public volatile long value = 0L; } // long padding避免false sharing // 按理说jdk7以后long padding应该被优化掉了,但是从测试结果看padding仍然起作用 public final static class VolatileLongPadding { public long p1, p2, p3, p4, p5, p6, p7; public volatile long value = 0L; volatile long q0, q1, q2, q3, q4, q5, q6; } /** * jdk8新特性,Contended注解避免false sharing * Restricted on user classpath * Unlock: -XX:-RestrictContended */ @sun.misc.Contended public final static class VolatileLongAnno { public volatile long value = 0L; } }
只有一个 long 类型的变量
public final static class VolatileLong {
public volatile long value = 0L;
}
定义一个 VolatileLong 类型的数组,然后让多个线程同时并发访问这个数组, 这时可以想到,在多个线程同时处理数据时,数组中的多个 VolatileLong 对象可 能存在同一个缓存行中。
private static VolatileLongAnno[] longs = new VolatileLongAnno[NUM_THREADS];
static {
/*将数组初始化*/
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLongAnno();
}
}
运行后,可以得到运行时间
花费了 39 秒多。
我们改用进行了缓存行填充的变量
花费了 8.1 秒,如果任意注释上下填充行的任何一行,时间表现不稳定,从 8 秒到 20 秒都有,但是还是比不填充要快。具体原因目前未知。
从 8 秒到 20 秒都有,但是还是比不填充要快。具体原因目前未知。
花费了 7.7 秒。
由上述的实验结果表明,伪共享确实会影响应用的性能。
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间 的共享变量存储在主内存(MainMemory)中,每个线程都有一个私有的本地内 存(LocalMemory),本地内存中存储了该线程以读/写共享变量的副本。本地 内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存 器以及其他的硬件和编译器优化。
除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时, 为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型。
数据依赖性:
如果两个操作访问同一个变量,且这两个操作中有一个为写操 作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列 3 种类型,上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
例如:
as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行 度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必 须遵守 as-if-serial 语义。
/** * 控制依赖 */ public class ControlDep { int a = 0; volatile boolean flag = false; public void init() { a = 1; // 1 flag = true; // 2 //....... } public synchronized void use() { if (flag) { // 3 int i = a * a; // 4 } //....... } }
Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类 型的处理器重排序,从而让程序按我们预想的流程去执行。
JMM 把内存屏障指令分为 4 类
StoreLoadBarriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的 效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支 持)。
想一下,为啥线程安全的单例模式中一般的双重检查不能保证真正的线程安全?
上面的定义看起来很矛盾,其实它是站在不同的角度来说的。
回顾我们前面存在数据依赖性的代码:
站在我们 Java 程序员的角度:
但是仔细考察,2、3 是必需的,而 1 并不是必需的,因此 JMM 对这三个 happens-before 关系的处理就分为两类:
采用了不同的策略,如下:
JMM 为我们提供了以下的 Happens-Before 规则:
可以把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写 操作做了同步
所以 volatile 变量自身具有下列特性:
int a = 0;
volatile boolean flag = false;
public void init() {
a = 1; // 1
flag = true; // 2
//.......
}
public synchronized void use() {
if (flag) { // 3
int i = a * a; // 4
}
//.......
}
如果我们将 flag 变量以 volatile 关键字修饰,那么实际上:线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值都被刷新到主内存 中。
在读 flag 变量后,本地内存 B 包含的值已经被置为无效。此时,线程 B 必须 从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共 享变量的值变成一致。
如果我们把 volatile 写和 volatile 读两个步骤综合起来看的话,在读线程 B 读 一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的 值都将立即变得对读线程 B 可见。
volatile重排序规则表
总结起来就是:
volatile的内存屏障
在 Java 中对于 volatile 修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。
volatile 写:
volatile 读:
在构造线程的类时,我们有种方式就是让类中所有的成员变量都不可变,利 用的就是 final 关键字,那么这个 final为何可以做到呢?重排序这种优化动作对 构造方法,一样也是存在的。这就说明,一个成员变量加了 final 关键字后, JMM一定是做了相关处理的。
对应 final 域,编译器和处理器需要遵守两个重排序规则。我们以代码来说明:
/** * 类说明:final的内存语义 */ public class FinalMemory { int i; // 普通变量 final int j; // final变量 static FinalMemory obj; public FinalMemory() { // 构造函数 i = 1; // 写普通域 j = 2; // 写final域 } public static void writer() {// 写线程A执行 obj = new FinalMemory(); } public static void reader() { // 读线程B执行 FinalMemory object = obj; // 读对象引用 int a = object.i; // 读普通域 int b = object.j; // 读final域 } }
我们假设一个线程 A 执行 writer 方法,随后另一个线程 B 执行 reader 方法。
看 write()方法,只包含一行代码 obj=newFinalMemory();。这一行代码包含 两个步骤:
假设线程 B 读对象引用(FinalMemoryobject=obj)与读对象的成员域之间 (inta=object.i;intb=object.j)没有重排序,下面的图是一种可能的执行时序:
从上面可能的时序图中我们可以看到,写普通域被编译器重排序到了构造函 数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作, 被写 final 域的重排序规则“限制”到了构造函数之内,读线程 B 正确读取了 final 变量初始化之后的值。
总结:写 final 域的重排序规则可以确保在对象引用为任意线程可见之前, 对象的 final域已经被正常的初始化了,而普通域不具有这样的保证。
在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止 处理器重排序这两个操作。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
reader()方法包含 3 个步骤:
我们假设写线程 A 没有发生任何重排序,则下图是一种可能的时序:
读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该 域还没有被线程 A 写入,所以上面的是一个错误的读取操作。但是读 final 域的 重排序规则把读对象 final 域的操作“限定”在读对象引用之后,该 final 域已经 被 A 线程初始化了,是一个正确的读取操作。
总结:读 final 域的重排序规则可以确保在读一个对象的 final 域之前,一定 会先读包含这个 final 域的对象的引用。
我们以代码 FinalRefMemory 来说明
public class FinalRefMemory { final int[] intArray; // final 是引用类型 static FinalRefMemory obj; public FinalRefMemory() { // 构造函数 intArray = new int[1]; // 1 intArray[0] = 1; // 2 } public static void writerOne() { // 写线程A执行 obj = new FinalRefMemory();// 3 } public static void writeTwo() { // 写线程B执行 obj.intArray[0] = 2; // 4 } public static void reader() { // 读线程C执行 if (obj != null) { // 5 int temp1 = obj.intArray[0];// 6 } } }
在上面的代码中,final 域是一个引用类型,它引用了一个 int 类型的数组, 对于引用类型,写 final 域的重排序规则对编译器和处理器增加了一下的约束: 在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把 这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
我们假设线程 A 先执行 write0 操作,执行完后线程 B 执行 write1 操作,执 行完后线程 C 执行 reader 操作,下图是一种可能的执行时序:
1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是 把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。
JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象 的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素 的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读 线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可 预知。
如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线 程 C 之间需要使用同步(lock 或 volatile)来确保内存可见性。
写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引 用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用 为其他线程所见,也就是对象引用不能在构造函数中逃逸。
我们以 FinalEscape 为例来说明
/** * 类说明:不能让final引用从构造方法中溢出 */ public class FinalEscape { final int i; static FinalEscape obj; public FinalEscape() { i = 10; //写final域 obj = this; //this引用溢出 } public static void writer(){ new FinalEscape(); } public static void reader(){ if(obj!=null){ //3 int temp = obj.i; //4 } } }
假设一个线程 A 执行 writer()方法,另一个线程 B 执行 reader()方法。这里的 操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数 的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read()方法的线程仍然可 能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重 排序。
因此在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。
/** * 类说明:演示锁的内存语义 */ public class SynMemory { private static boolean ready; private static int number; private static class PrintThread extends Thread{ @Override public void run() { while(!ready){ System.out.println("number = "+number); } System.out.println("number = "+number); } } public static void main(String[] args) { new PrintThread().start(); SleepTools.second(1); number = 51; ready = true; SleepTools.second(5); System.out.println("main is ended!"); } }
我们可以看见子线程同样可以中止,为何?我们观察 System.out.println 的实现,
结合前面锁的内存语义,我们可以知道,当进入 synchronized 语句块时,子线程会被强制从主内存中读取共享变量,其中就包括了 ready 变量,所以子线程同样中止了。
一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态, 它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和 释放锁的效率。
引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多 次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的 CAS 操作。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中, 同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步 的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种 情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占 锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标 准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的 运行性能。
偏向锁获取过程:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向 锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁 的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首 先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复 到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去 执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级 为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stoptheword 操 作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致 进入安全点,安全点会导致 stw,导致性能下降,这种情况下应当禁用。
开启偏向锁:-XX:+UseBiasedLocking-XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当 第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向 (锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程 的栈帧中建立一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的 MarkWord 的拷贝,官方称之为 DisplacedMarkWord。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 MarkWord 更新为指向 Lock Record 的指针,并将 Lockrecord 里的 owner 指针指向 objectmarkword。如果更 新成功,则执行步骤 4,否则执行步骤 5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
如果这个更新操作失败了,虚拟机首先会检查对象的 MarkWord 是否指向当前 线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进 入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失 败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞 争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”, MarkWord 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也 要进入阻塞状态。
如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化: 同步消synchronizationElimination,如果一个对象不会逃逸出线程,则对此变 量的同步措施可消除。
锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可 能发生共享数据竞争,则会去掉这些锁。
锁粗化:将临近的代码块用同一个锁合并起来。
消除无意义的锁获取和释放,可以提高程序运行性能。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。