private static List<Integer> list;
static {
list = new ArrayList<Integer>(20);
list.add(0);
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
new Thread(()->{
Thread.sleep(200);
list.set(0,1);
System.out.println("set 1");
}).start();
new Thread(()->{
while (list.get(0) == 0){
}
System.out.println("end");
latch.countDown();
}).start();
latch.await();
}
结果打印出"set 1"后,程序死循环无法退出。线程1中对list的操作对线程2不可见,导致while死循环无法退出。在多线程环境下,List具备可见性问题
private volatile static List<Integer> list;
static {
list = new ArrayList<Integer>(20);
list.add(0);
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
new Thread(()->{
Thread.sleep(200);
list.set(0,1);
System.out.println("set 1");
}).start();
new Thread(()->{
while (list.get(0) == 0){
}
System.out.println("end");
latch.countDown();
}).start();
latch.await();
}
仅添加了volatile
关键字修饰,其他没有任何变化。
结果打印出"set 1"和“end”后,主线程退出。线程1对list做的操作貌似对线程2是可见的。
private static List<Integer> list;
private static volatile boolean flag;
static {
list = new ArrayList<Integer>(20);
list.add(0);
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
new Thread(()->{
Thread.sleep(200);
list.set(0,1);
System.out.println("set 1");
}).start();
new Thread(()->{
while (list.get(0) == 0){
boolean v = flag;
}
System.out.println("end");
latch.countDown();
}).start();
latch.await();
}
结果打印出"set 1"和“end”后,主线程退出。线程1对list做的操作貌似对线程2是可见的。
对于实验一,我们很容易理解,在多线程下产生的可见性问题。面对这种问题,我们往往会通过volatile
关键字来保证可见性,从而解决该问题。
对于普通的基本类型如int
等没有什么疑问,但在这种引用类型,如数组List
,添加该关键字到底保证的是List
自己还是同样包含了其内部元素一起的可见性?
通过实验二,我们在List前增加了Volatile
,从效果上来看好像真的是对内部元素有用,线程2感知到了线程1中对List的操作,但事实并没有这么简单。
我们通过实验三,去掉了List前的Volatile
,增加了看似毫无用处的Volatile
修饰的flag,然后在线程2中每次都去访问这个flag,结果竟然也能感知到线程1的操作。
我们平时一直所知的,是对于Volatile
修饰的变量,禁止指令重排序,且在修改时会立即刷到主存,访问也是直接去主存拿。
对于引用类型的,Volatile
不能保证其内部元素的可见性,仅仅能保证该引用指向的对象本身的可见性。
实际上从Java5开始,Volatile
保证的已经不仅仅是被修饰的变量了,而是所有变量。
Volatile
变量时,会去主存中读取该变量的值,同时也会将在该行为之后的变量的值一并从主存中拿取Volatile
变量时,会将该操作之前发生的修改统统刷新到主存中去。举个栗子:
private int a;
private volatile int b;
// Volatile保证了指令重排序的规则,不会改变下面两个操作的顺序
public void read(){
int bb = b;//从主存中读取b的最新值的同时,也会将a读取进来
int aa = a;
}
public void write(){
a = 1;
b = 1;//将b的更新操作写入主存的同时,也会将a的更新写入主存
}
在实验三中,while循环中会对volatile
修饰的flag读取,然后由于循环又去访问list,因此在读取flag时会从主存中读取,同时会将此时的list数据从主存中读进来。因此造成了线程2成功看到了线程1对list的操作。
同理,在实验二中,list本身是volatile
的,在访问list之前必然会先访问list引用,因此此时会去主存中找,顺带把后面的操作即list内部元素一并从主存拿过来,从而造成了线程2成功看到了线程1对list的操作。
实验二的分析中我们可以再次猜测一下,在访问list引用将此时list内部元素从主存中拿过来后,在下次访问list引用之前的这段时间,线程1对list做到操作应该仍然是对线程2不可见的。
我们基于该假设再次做个实验
private volatile static List<Integer> list;
static {
list = new ArrayList<Integer>(20);
list.add(0);
list.add(0);
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
new Thread(()->{
Thread.sleep(200);
list.set(0,1);
System.out.println("set 1");
Thread.sleep(200);
list.set(1,1);
}).start();
new Thread(()->{
List<Integer> l;
while ((l=list).get(0) == 0){
}
System.out.println("end");
while (l.get(1) == 0){
}
latch.countDown();
}).start();
latch.await();
}
打印出了"set 1"和"end",但主线程陷入死循环没有退出。
可以看到当线程2通过访问list引用感知到线程1的变化后,使用新的引用l
指向了数组,并在第二个while循环中对数组进行监听。
由于l
没有可见性的保证,因此线程1中的操作是不可见的,从而验证了我们之前的想法。
因此如果要让每次都能感知到变化,需要修改list底层的数组类型为volatile
的才行。
在JUC包下的并发容器中,底层元素容器都采用了volatile
修饰来保证可见性,但这会对性能造成影响。
同时我们可以使用Atomic包下的原子操作类型来保证原子性和可见性,如AtomicReferenceArray
,AtomicIntegerArray
等