原子性
原子是最小单元、不可再分的意思。原子性是指某个操作在获取CPU时间时,要么就给它足够时间,让这个操作执行完,要么就不执行这个操作,执行时不能出现上下文切换(把CPU时间从一个线程分配到另一个线程)。
Java中对变量的读取和赋值都是原子操作,但long、double类型除外,只有使用volatile修饰之后long、double类型的读取和赋值操作才具有原子性。除此之外Java还提供了几个常用的原子类,原子类的方法是具有原子性的方法,也就是说原子类在执行某个方法的过程中不会出现上下文切换。
前面两篇我们讲的锁,锁可以保证当两个线程同时对一个整型变量进行自增操作时的正确性。自增操作分为三步:1. 读取变量的值;2. 将这个值加一;3. 将加一后的值写入到变量中。不使用锁导致计算结果错误的根源就是一个线程在执行这三个操作的过程中发生了上下文切换。通过使用锁可以保证在进行这三个操作的过程中只有一个线程执行临界区的代码,其余想获取锁的线程都被阻塞了(注:这时也是会发生上下文切换的,只是不会把CPU时间分配给阻塞线程而已);而使用原子类可以使CPU在自增操作时不切换时间片,从而在根本上解决了问题。
我们使用原子类来进行变量自增:
-
class IncreaseThread implements Runnable {
-
@Override
-
public void run() {
-
for(
int i=
0;i <
100000; i++) {
-
AtomicIntegerTest.value.incrementAndGet();
-
}
-
}
-
}
-
public
class AtomicIntegerTest {
-
public
static AtomicInteger value =
new AtomicInteger(
0);
-
public static void main(String[] args) throws InterruptedException {
-
ExecutorService exec = Executors.newCachedThreadPool();
-
exec.execute(
new IncreaseThread());
-
exec.execute(
new IncreaseThread());
-
exec.shutdown();
-
Thread.sleep(
5000);
//等待两个线程执行结束
-
System.out.println(
"Value = " + value);
-
}
-
}
五秒后输出如下结果:
Value = 200000
我们使用线程池创建了两个线程,这两个线程同时对AtomicIntegerTest的value属性进行自增操作。AtomicInteger是int类型对应的原子类,调用这个类的incrementAndGet()方法可以实现自增,并且不需要使用锁的保护就可以得到正确的结果。
除了AtomicInteger之外,Java中还实现了AtomicLong、AtomicBoolean、AtomicReference等原子类,其使用方法与AtomicInteger类似,读者可自行测试。
易变性
Java volatile关键字用于通知虚拟机这个变量具有易变性,那么什么是易变性呢?易变性比原子性更为复杂,在工业上导致的问题也更多,其中易变性有两层含义:
1. 可见性
Java虚拟机会为每个线程分配一块专属的内存,称之为工作内存;不同的线程之间共享的数据会被放到主内存中。工作内存主要包含方法的参数、局部变量(在函数中定义的变量),这些变量都是线程私有的,不会被其它线程共用。实例的属性、类的静态属性都是可以被共享的,每个线程在操作这些数据时都是先从主内存中读取到工作内存再进行操作,操作结束后再写入到主内存中。可见性要求线程对共享变量修改后立即写入到主内存中,线程读取共享变量时也必须去主内存中重新加载,不能直接使用工作内存中的值。Java中的变量在默认情况下是不具有可见性的,需要用volatile关键字修饰才具有可见性,让我们做一个测试:
-
class NewThread implements Runnable {
-
public
volatile
static
long value;
-
public void run() {
-
while(VolatileTest.run) {
-
value++;
-
}
-
System.out.println(
"Done");
-
}
-
}
-
public
class VolatileTest {
-
public
static
boolean run =
true;
-
public static void main(String[] args) throws InterruptedException {
-
ExecutorService exec = Executors.newCachedThreadPool();
-
exec.execute(
new NewThread());
-
exec.shutdown();
-
Thread.sleep(
500);
-
run =
false;
-
System.out.println(
"run: " + run);
-
System.out.println(
"value: " + NewThread.value);
-
Thread.sleep(
500);
-
System.out.println(
"value: " + NewThread.value);
-
}
-
}
一秒后输出如下结果,并且程序始终没有停止:
run: false
value: 1655066633
value: 3319764420
在VolatileTest类中定义了一个静态的布尔属性,这个布尔属性用于控制新建线程中是否继续循环,每次循环都对value值加一,为了保证value的值对其它线程可见,我们使用了volatile来修饰它。启动新线程0.5秒后我们将run的值改成false并打印出当前的value值,再过0.5秒又打印了一遍,这次的值比上一个值更大,说明新线程并没有因为run值变成了false而停止,因为新线程没有看到run值的变化。示意图如下所示:
如果我们将run变量用volatile修饰,打印两次value的值就会得到相同的结果,感兴趣的读者可以自行测试。
2. 有序性
易变性另一层含义就是有序性,是指禁止CPU对指令重排优化,默认情况下CPU会对指令进行合理的重排优化,重排优化仅保证单线程运行时结果的正确性,不保证执行顺序。但是虚拟机不会对指令任意重排,而是有一定的规则。
不可重排的情况:
-
int a =
1;
-
int b = a;
上面代码的两个语句之间存在依赖关系,如果两个语句的执行顺序被改变将导致逻辑的变化,准确的说会导致执行错误。
可重排的情况:
-
int a =
1;
-
int b =
1;
-
a++;
上面代码中是可以发生指令重排的,其中只要保证第一行始终在第三行之前执行,就不会导致逻辑错误。虚拟机会根据执行的具体情况进行指令重排优化,在单线程执行时,这种重排不会导致程序的逻辑问题,而多线程并发执行时就会存在逻辑问题,伪代码如下:
-
int a;
-
int b;
-
-
//线程1执行initialize()方法
-
initialize() {
-
a =
1;
-
b =
1;
-
}
-
-
//线程2执行
-
monitor() {
-
if(b ==
1) {
-
print(
"初始化完毕");
-
}
-
else {
-
print(
"初始化还没有结束");
-
}
-
}
两个线程分别执行initialize()方法和monitor()方法,如果没有发生指令重排,线程2根据b是否等于1来判断初始化是否结束是没有逻辑问题的。但是初始化a,b两个变量之间没有依赖关系,虚拟机是可以根据需要来指令重排的,这时再根据b是否等于1来判断就是错误的,虚拟机有可能先初始化变量b后初始化变量a。除了保证可见性之外,volatile第二个功能就是保证有序性,即禁止虚拟机对该变量进行指令重排。
3. 锁与易变性
volatile保证了易变性,锁不仅保证了易变性,还保证了线程间的互斥性,即所有线程在进入临界区之前都必须排队,当使用锁时不需要临界区内所有的变量都不需要声明为volatile。volatile相当于是轻量级的锁,volatile关键字的功能没有锁更强大,但是其性能也会比锁更好。
总结
本章讲了原子性和易变性,原子性是指CPU在执行指令集的过程中不能发生上下文切换,易变性指变量的变化对所有线程可见,并且JVM对该变量的操作不能发生指令重排。理论上讲原子性和易变性是两个平行的概念,然而Java中的原子类(AtomicInteger等)在实现的时候使用了volatile关键字,所以Java中的原子类的操作也具有易变性。实际上原子性+易变性>锁,CPU在执行临界区内的代码时也会发生上下文切换,比如临界区的代码是打印一万个Hello World,一个线程执行临界区,另一个线程负责打印World Hello,执行代码会发现万军丛中有一个World Hello,从而证明CPU在执行临界区代码的时候也会发生上下文切换。然而在逻辑上我们可以理解为原子性+易变性=锁,因为即使临界区内发生了上下文切换,其它线程也不会进入临界区,因此不会对临界区的结果造成影响。
公众号:今日说码。关注我的公众号,可查看连载文章。遇到不理解的问题,直接在公众号留言即可。