最近遇到了一个NullPointerException,虽然量不大,但是很怪异,大致长这个样子
这是个什么空指针?居然说我LinkedList.iterator().hasNext()方法有问题?可是我就是正常的调用hasNext()啊,怎么就抛出来这种异常了呢?
private static final class Link<ET> {
ET data;
Link<ET> previous, next;
Link(ET o, Link<ET> p, Link<ET> n) {
data = o;
previous = p;
next = n;
}
}
Link其实就是我们所说的Node, 从这里可以看出来这是一个双向链表
public LinkedList() {
voidLink = new Link(null, null, null);
voidLink.previous = voidLink;
voidLink.next = voidLink;
}
voidLink也是一个Link类型,LinkedList为了方便管理,内部实现其实是一个循环双向链表,voidLink就是连接首尾的那个节点,使用这么一个voidLink也可以减少大量空指针判断和保护,若链表为空,voidLink的previous和next都指向自己
private E removeFirstImpl() {
Link first = voidLink.next;
if (first != voidLink) {
Link next = first.next;
voidLink.next = next;
next.previous = voidLink;
size--;
modCount++;
return first.data;
}
throw new NoSuchElementException();
}
这也就是LinkedList的出队操作了,惊讶的发现并没有任何一个中间环节使链表上的某一个指针指向了null,那再来看一下add方法
private boolean addLastImpl(E object) {
Link oldLast = voidLink.previous;
Link newLink = new Link(object, oldLast, voidLink);
voidLink.previous = newLink;
oldLast.next = newLink;
size++;
modCount++;
return true;
}
这是对应的入队操作,也没有发现任何一个中间步骤让链表上的某个指针指向null,那再来看下报错的地方
LinkIterator(LinkedList object, int location) {
list = object;
expectedModCount = list.modCount;
if (location >= 0 && location <= list.size) {
// pos ends up as -1 if list is empty, it ranges from -1 to
// list.size - 1
// if link == voidLink then pos must == -1
link = list.voidLink;
if (location < list.size / 2) {
for (pos = -1; pos + 1 < location; pos++) {
link = link.next;
}
} else {
for (pos = list.size; pos >= location; pos--) {
link = link.previous;
}
}
} else {
throw new IndexOutOfBoundsException();
}
}
最终一系列的调用,调用到这个构造方法里,location恒等于0,也就是说必然执行到
link = link.previous;
public boolean hasNext() {
return link.previous != list.voidLink;
}
报错信息显示这个link是空,这个link是LinkIterator的一个成员变量
private static final class LinkIterator<ET> implements ListIterator<ET> {
int pos, expectedModCount;
final LinkedList list;
Link link, lastLink;
//...
}
如刚才所分析,这个link明明在LinkedListIterator的构造方法里赋值成了voidLink.previous,而这个voidLink.previous在LinkedList构造方法里就赋值成了它自己啊,在之后的poll()和add()之后都不会再有任何一个中间步骤变成null,那问题出在哪里了?
public class PreloadManager {
private volatile static PreloadManager sInstance;
public static PreloadManager getInstance() {
if (sInstance == null) {
synchronized(PreloadManager.class) {
if (sInstance == null) {
sInstance = new PreloadManager();
}
}
}
return sInstance;
}
//...
}
sInstance必须要用volatile修饰,有的同学可能说是为了保证线程可见性,但是其实synchronized也可以保证线程可见性(有兴趣的同学可以自己去验证一下),那volatile是为了什么呢?答案是禁止指令重排序(虽然这种说法并不严谨)
因为getInstance里面都加上了同步synchronized保护,所以假如执行构造器的时候进行了指令重排序,先执行了ret指令,把对象地址赋值给了sInstance变量之后,才进行构造器里的赋值,这时候恰好进行了线程切换,切换到了线程B,这个时候如果线程A也恰好进行了从工作内存写入到堆内存(这是JVM里的概念,从计算机硬件的角度来说就是从高速缓存中写入到主存中,注:JVM并没有规定应该何时进行写入,所以加上了“如果”两个字),那么就会检测到sInstance不是null,然后访问成员变量,问题就出现了
那么问题就确定了,就是在构造器里因为重排引起的问题!再来贴一下这段代码
public LinkedList() {
voidLink = new Link(null, null, null);
voidLink.previous = voidLink;
voidLink.next = voidLink;
}
也就是说重排序之后,线程切换恰好发生在new Link(null, null, null)的时候,导致在另外一个线程调用iterator()时,LinkIterator.link被赋值给了voidLink.previous,然后就出现了空指针,这个问题可以使用volatile解决,那么这个volatile为什么会有作用呢?
(以下内容是抄过来的)
as-if-serial 语义的意思指:不管怎么重排序, 单线程下的执行结果不能被改变(简直就是废话)
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
是否能重排序 | 第二个操作 | ||
---|---|---|---|
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | N | ||
volatile读 | N | N | N |
volatile写 | N | N |
Java的规范要求只需要保证乱序在单线程里看起来和顺序执行一样就OK了
既然前面讲了问题所在,也说到了volatile能解决这个问题,那到底为啥能解决呢?
其实这种问题不只是出现在Java上,毕竟一切的尽头都是机器指令,所以只要运行在计算机上都会有这种问题,所以其实指令集也针对乱序在多线程时出现的问题做出了拓展,这里我们以x86为例
上述只是x86指令集下的相关指令,不同的指令集可能barrier的效果并不一样,fence和lock是两种实现内存屏障的方式(毕竟一个指令集很庞大)
Java这个时候又来了一波抽象,他把barrier分成了4种
屏障类型 | 指令示例 | 解释 |
---|---|---|
LoadLoadBarriers | Load1; LoadLoad;Load2 | 确保 Load1 数据的装载,之前于Load2 及所有后续装载指令的装载。 |
StoreStoreBarriers | Store1; StoreStore;Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于Store2 及所有后续存储指令的存储。 |
LoadStoreBarriers | Load1; LoadStore;Store2 | Load1 数据装载,之前于Store2 及所有后续的存储指令刷新到内存。 |
StoreLoadBarriers | Store1; StoreLoad;Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于Load2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
注意,这是Java内存模型里的内存屏障,只是Java的规范,对于不同的处理器/指令集,JVM有不同的实现方式,比如有可能在x86上一个StoreLoad会使用mfence去实现(当然这只是我的意淫)
再次说明一下,这四个barrier是JVM内存模型的规范,而不是具体的字节码指令,因为你可以看到volatile变量在字节码中只是一个标志位,javap搞出来的字节码中并没有任何的barriers,只是说JVM执行引擎会在执行时会插一个对应的屏障,或者说在JIT/AOT生成机器指令的时候插一条对应逻辑的barriers,说句人话,这个barrier不是javac插的!所以你通过javap看不到,如果想要看到volatile的作用,可以把字节码转成汇编(很多很多),具体指令如下
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly [ClassName]
到这里我们可以看到,其实不存在任何一种指令能够禁止乱序执行,我们能做到的只是把这一堆指令根据”分段”,比如在指令中插入一条full barrier指令,然后所有指令被分成了两段,barrier前面的我们称之为程序段A,后面的称之为程序段B,通过barrier我们能够保证A所有指令的执行都在B之前,也就是说,程序段A和B分别都是乱序执行的。
再举个例子,假如我们在一个变量的赋值前后各加一个barrier
full barrier;
instance = new Singleton(); //C
full barrier;
那么在外界看起来就好像是禁止了C处指令重排一样,其实C处又可以拆成一堆指令,这一堆指令在两个barrier之间其实又是乱序的
上面我们说了volatile的两大语义:
现在我们来看看JVM到底会对volatile进行怎么样的处理
此处盗一波图(来自《深入理解Java内存模型》,网上可以找到)
结合四种屏障的效果我们来看一下volatile是怎么实现我们最熟知的可见性和解决重排序问题的(上面说到volatile的具体语义已经一目了然了,但是表中的语义貌似和我们平时对volatile的认知关系不大)
(volatile的具体语义是指上述表中普通读写和volatile读写是否可以重排的关系)
synchronized我们都知道就是锁,但是在java中,synchronized也是可以保证线程可见性的,我们知道信号量只能实现锁的功能,它是没有我们之前说过的内存屏障的功能的,那其实synchronized在代码块最后也是会加入一个barrier的(应该是store barrier)
final除了我们平时所理解的语义之外,其实还蕴含着禁止把构造器final变量的赋值重排序到构造器外面,实现方式就是在final变量的写之后插入一个store-store barrier
public class Singleton {
public volatile static Singleton sInstance = new Singleton();
public LinkedList mList = new LinkedList<>();
public static void main(String[] args) {
sInstance.mList.add("A");//A
}
}
在A处,add函数内部是不是也被”框”在(sIntance的)屏障中间了呢?
我认为不会,因为sInstance.mList在是一个load操作,add()又是另外一个操作,所以我觉得add应该会在barrier的外面
我的想法是
//store-store barrier
LinkedList<String> list = sInstance.mList;
//store-load barrier
mList.add("A");
(有可能是我理解错了)
内存屏障禁止了CPU恣意妄为的重排序,所以肯定是会降低一定的效率,不过比synchronized应该还是要好一些的
也不要过度使用volatile,如果是多个线程共有的变量,而且不能确保是没问题的,那么最好加上volatile(这也提醒我们,尽量减少多线程的公有变量)