对Java中volatile关键词的思考

在Java多线程编程中,volatile关键词是绕不过去的坎,不能以为使用了volatile就能顺利解决多线程并发问题了…

常见并发问题

对于如下的常见并发代码,创建20个线程,每个线程都对同一个变量累加100次,本来应该输出结果2000,但通常会事与愿违的输出1946、1954等小于2000的结果。这是因为多个线程在同时取出相等的count值自加后,写回内存时发生冲突导致最终结果比实际结果小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MulThread implements Runnable {
public static int count = 0;

public static void main(String[] args) throws InterruptedException {
MulThread main = new MulThread();
for (int i = 0; i < 20; i++) {
new Thread(main).start();
}

// 等所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(count);
}

@Override
public void run() {
for (int i = 0; i < 100; i++) {
count++;
}
}
}

输出结果:

1
1946、2000

使用volatile就能解决问题?

将上面的代码中count变量加上volatile修饰,是不是就能解决多线程并发问题得到正确结果2000呢?事实证明不是这样的,有时候依然会输出小于正确结果的值。看来使用volatile并没有直接解决并发问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MulThread implements Runnable {
public static volatile int count = 0;

public static void main(String[] args) throws InterruptedException {
MulThread main = new MulThread();
for (int i = 0; i < 20; i++) {
new Thread(main).start();
}

// 等所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(count);
}

@Override
public void run() {
for (int i = 0; i < 100; i++) {
count++;
}
}
}

输出结果:

1
1965、2000、2000

为什么volatile变量在并发下不一定安全?

volatile变量保证该变量对所有线程的可见性,这里可见性是指当一条线程修改了这个变量的值,新值对其他线程是可以立即得知的。普通变量的值在线程中传递通过主内存来完成。

在上面的count++这行代码中,这行代码由4条字节码指令构成,当getstatic把count值取到操作栈顶时,volatile保证了count值此时是正确的,但在执行iconst_1、iadd这些指令时,其他线程已经把count值加大了,在操作栈顶的值就变成了过期的数据,所以putstatic把较小的值同步回主内存中。

1
2
3
4
getstatic
iconst_1
iadd
putstatic

总的说,由于volatile关键字只保证可见性,但由于运算操作并不一定是原子的,所以仍会出现并发问题,需要通过加锁来保证原子性。

volatile有哪些作用?

  • 保证变量对所有线程的可见性

    如前文所说,volatile保证变量对所有线程的修改是立即可见的,但不能得出volatile变量在并发下是安全的结论。自增运算count++不是原子的,仍会出现并发问题。
  • 禁止指令重排序优化

    普通变量不能保证变量赋值操作顺序与程序代码执行顺序一致,因为CPU允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。volatile变量在本地代码中插入内存屏障指令保证处理器不发生乱序执行。
  • 保证long、double类型变量读取的原子性

    Java内存模型允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,允许不保证long、double类型的原子性。线程在读取没被volatile声明的变量时,可能读取到一个半个变量的数值。但几乎所有的虚拟机都将64位数据作为原子操作来对待,所有一般情况下无需将long、double声明为volatile。

参考资料:《深入理解Java虚拟机》