Java并发编程笔记(一): 线程安全
一:什么是线程安全?
在给出具体的定义之前,先来看看一个常见的线程安全工具,ThreadLocal。ThreadLocal简单来说就是一个Map,Key是线程Thread,Value就是保存的变量。他保证每个线程都有变量的一份拷贝,各个线程对该变量的操作都不会影响到其他线程。因为每个线程都有变量的一份拷贝,所以这份拷贝就是线程私有的,即线程安全。
在《Java并发编程实战》中作者给出线程安全的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类是线程安全的。
特殊的是:单线程也可以理解成特殊的多线程,如果某个类在单线程环境下都不是正确的,即结果不符合预期,那么这个类在多线程环境下也肯定不是正确的。即该类肯定不是线程安全类。
上面的定义说到多线程是会交替运行的,即我们无法确定线程执行的顺序,那如何在多线程的环境下保证线程安全呢?答案是:同步。这里同步不单单指Java里的synchronized关键字,其实锁,CAS等都可以称为同步,只是实现的方式不同,目的都是一样的。我们先来看看一个重要的性质,原子性。
二:原子性
一个简单的场景
就拿计数器这个例子来说,我们构造一个场景,开启4个线程来对同一个共享变量做操作(在本例里是+1操作),如下代码所示:
public class CountExample {
private int count = 0;
public static void main(String[] args) throws InterruptedException {
CountExample countExample = new CountExample();
ExecutorService executors = Executors.newFixedThreadPool(4);
for (int i = 0; i < 1000000; i++) {
executors.submit(countExample::add);
}
//停止接受任务
executors.shutdown();
//等待任务完成或者超时
executors.awaitTermination(2000, TimeUnit.SECONDS);
System.out.println(countExample.getCount());
}
public void add() {
count = count + 1;
}
public int getCount() {
return count;
}
}
运行代码(多运行几次),可以某次运行的结果会小于1000000,显然也不符合我们的预期,根据上面线程安全的定义可以确定,这个类现在不是一个线程安全的类。尝试修改一下代码,在add方法上使用synchronized关键字,如下所示:
public synchronized void add() {
count = count + 1;
}
再次运行代码(多运行几次),可以看到几乎每次结果都是1000000,当然这可能是运行次数太少,也许下一次就会出现少于1000000的情况了,但是至少出现的几率比上面一种少了很多,不是吗?
大量的实践表明,上述使用synchronized关键字的类确实是一个线程安全的类,他能在多线程交替执行的情况下保证正确性。
场景分析
上面两种代码,差别仅仅是一个synchronized关键字,为什么加入这个关键字之后就能保证线程安全了呢?
对一个变量进行+1操作,在JVM上执行的时候会被拆解成“读取-修改-写入” 这三个步骤,而线程就是在执行指令,其执行的顺序不可预测,也就是说可能A线程读取到count的值为1,这个时候被B线程抢占CPU,B线程也读取count,这时候B读取到值也是1,然后B线程继续执行到结束部分将结果2写入到内存,然后A线程开始恢复执行,他对count+1,最终得到结果2写入写入到内存。这里就有问题了,A,B两个线程执行add操作,我们期望的是count + 2,即得到结果3,但是在这种情况下,仅仅得到2。就好像丢失了一次操作。
上述情况暴露了一个问题,就是结果是否正确,完全取决于线程执行的顺序,直白一些就是靠运气。这种情况有一个正式的名字:竞态条件(Race Condition)。最常见的竞态条件类型就是“先检查后执行”操作。对于我们场景,“先检查”就是先观测count的值,“后执行”就是将观测到的值+1写回到内存中。
解决这个问题的最简单的方式就是将“先检查后执行”这一系列操作构造成原子操作。这样就能保证“先检查后执行”这一系列操作就像在执行一条指令一样,从而解决这个问题。synchronized关键字可以保证原子性(这里对synchronized的作用描述并不严谨,后面文章会讲到)。从而保证例子中的CountExample类的线程安全。
三:锁
synchronized关键字其实是一种锁,更严谨的说法是Java内置锁。锁能够开辟出一个被称为“临界区”的区域。在这个区域内,只有获取到锁的线程才能进入到临界区执行。特别地,如果这个锁的性质是排他,那么同一时刻仅有一个线程能够执行临界区的代码。说到这,为什么synchronized可以得到正确的结果就不难理解了吧。
关于锁更多的内容,后续文章会讲到
总结
线程安全是多线程并发中最基础,最常见的问题,几乎所有手段最终的目的都是为了保证线程安全。所以能识别什么类是线程安全,什么类不是线程安全,才能采取有效的手段来解决问题。