1 概述
在多线程并发环境下,由于线程之间的执行顺序不可预测,所以如果多个线程对同一共享且可变的变量执行写操作,就可能导致该共享变量的状态发生错乱,这不是我们所期望的,甚至可能是造成系统崩溃的因素。我们一般把这种问题称为线程安全问题。
那什么是线程安全呢?如果你到网上搜索线程安全关键字,可能会得到很多不同版本的解释,大多数解释都没有错,只是会让人听完摸不着头脑,对线程安全仍然是一头雾水。在《Java并发编程》一书中,作者给出了一种线程安全的定义,我个人认为是一种比较好理解的定义,即:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
这句话中有几个关键字:多线程、调度方式、交替执行、主调代码不需要额外的同步或者协同、正确的行为。
- 多线程,指的就是多线程并发。
- 调度方式,这里主要指的就是线程调度。
- 交替执行,即线程的执行是交替的,隐含的信息就是线程的执行顺序是不可预测的。
- 主调代码不需要额外的同步或者协同,这句话的意思不是指该类不需要同步或者协同,而是指的调用方不需要“额外”的同步或协同,注意他们之间的区别。
- 正确的行为,即逻辑正确并且符合我们预期的行为。
我想,经过这番解释,上面的定义应该已经不难理解了。那有什么手段能保证线程安全呢,即不发生线程安全问题?要保证线程安全,主要有三大类方式,分别是:
- 不在线程之间共享变量。
- 将共享变量修改为不可变的变量。
- 在访问共享变量的时候采取同步操作。
这里先不对这三类方式做过多介绍,下面会逐一详细的介绍并且给出相应的解决方案。
需要说明的是,不是任何问题都能套用这三类解决方式,即有些线程安全问题可能只能采用上面列出的第三种方式:在访问共享变量的时候采取同步操作,而不能采用另外两种。
2 线程安全问题
先来看一个示例:
public class Main {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 50000; i++) {
count++;
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
多运行几次,应该可以看到输出的值并不总是一样的,我们的预期输出值应该是100000,但实际上每次运行的结果都是小于等于100000的值,也就是说运气好的话会得到预期的结果100000,运气不好那就不好说了,反正不会大于100000就是,这里其实就出现了线程安全问题。现在来分析一下为什么会出现线程安全问题,只有将问题分析清楚了,才能对症下药,解决问题。
在代码里开启了两个线程,这两个线程每个线程会执行50000次循环,在循环体里对同一个共享变量count进行自增操作,从输出上看,结果并不符合预期,主要有两个原因,一是count是一个共享的可变变量,二是线程的执行顺序不可预测,可能会发生如下情况:
线程1获取到count的值,该值是1,但由于线程调度的原因,线程1此时不得不停下来,而线程2进入运行状态,并且获取count的值,显然该值仍然是1,然后执行+1操作,并将值写回内存,即此时内存中count的值是2。之后线程1再次得到运行机会,并且继续执行上次执行完毕的操作,即将获取的count值执行+1操作,但线程1并不知道线程2对count的修改,也就说线程1仍然认为count的值是1,所以+1操作后得到count值为2,然后写回内存。
在这个过程中,执行了两次+1操作,但结果却和执行了一次+1操作一样,这就好像是有一个操作莫名其妙丢失了一样!这页是为什么输出的结果值总是小于等于100000,而不可能大于100000的原因。
现在我们知道导致线程安全的原因了,那该如何解决这个问题呢?
3 使用同步机制解决线程安全问题
同步的手段有很多,可以用内置锁synchronized,显式锁Lock来开辟一个临界区,该临界区具有互斥访问的特性,即只允许拿到锁的线程进入临界区,当线程离开临界区的时候需要释放锁,以便其他线程获取锁。还可以使用原子变量来包装共享变量,使其具有原子性,从而实现线程安全。
这里简单说一下原子性,在计算机底层有很多指令,有一类指令由多条指令组合而成,并且这些指令是不可分割的,即要么全部执行,要么全部不执行,这种指令就称作原子指令,也可以把这类指令称作具有原子性的指令。
3.1 使用内置锁synchronized
synchronized有四种方式(其实严格来说应该算是两种,细分成四种而已):
- 作用在实例方法上。
- 作用在静态方法上。
- 作用在代码块上,使用实例变量作为锁。
- 作用在代码块上,使用静态变量作为锁。
下面逐一介绍这四种方式。
3.1.1 作用在实例方法上
此时synchronized锁住的是调用该实例方法的对象,即如果有多个线程使用同一个对象多次调用该方法,那么这些调用之间的关系是互斥的,同一时刻只能有一个线程的调用能执行该方法。如下所示:
public class SynchronizedTest {
public synchronized void instanceMethod() {
for (int i = 0; i < 10; i++) {
print("instance method : " + i);
}
}
private void print(String message) {
System.out.println(message);
}
public static void main(String[] args) {
SynchronizedTest test = new SynchronizedTest();
ExecutorService service = Executors.newFixedThreadPool(4);
service.execute(test::instanceMethod);
service.execute(test::instanceMethod);
}
}
代码中创建了一个对象实例test,然后使用两个线程调用test.instanceMethod()方法,发现输出总是两次从0到9的遍历,即如下所示:
instance method : 0
instance method : 1
instance method : 2
instance method : 3
instance method : 4
instance method : 5
instance method : 6
instance method : 7
instance method : 8
instance method : 9
instance method : 0
instance method : 1
instance method : 2
instance method : 3
instance method : 4
instance method : 5
instance method : 6
instance method : 7
instance method : 8
instance method : 9
但如果没有synchronized修饰,则可能出现这样的输出(多试几次):
instance method : 0
instance method : 1
instance method : 0
instance method : 2
instance method : 1
instance method : 2
instance method : 3
instance method : 4
instance method : 5
instance method : 6
instance method : 7
instance method : 3
instance method : 4
instance method : 5
instance method : 8
instance method : 9
instance method : 6
instance method : 7
instance method : 8
instance method : 9
即两个调用的执行时交替执行的。这说明synchronized确实起到了同步互斥的作用。假设现在我们再创建一个对象,然后两次调用instanceMethod时使用不同的对象,结果会怎样?如下所示:
public static void main(String[] args) {
SynchronizedTest test1 = new SynchronizedTest();
SynchronizedTest test2 = new SynchronizedTest();
ExecutorService service = Executors.newFixedThreadPool(4);
service.execute(test1::instanceMethod);
service.execute(test2::instanceMethod);
}
//其他代码和之前一致
从输出可以看出,此时两个方法是交替执行的,没有发生互斥,这验证了最开始的那一段描述,即如果有多个线程使用同一个对象多次调用该方法,那么这些调用之间的关系是互斥的,同一时刻只能有一个线程的调用能执行该方法。那synchronized作用在静态方法上会是怎样的一个表现呢?
3.1.2 作用在静态方法上
public class SynchronizedTest {
public static synchronized void staticMethod() {
for (int i = 0; i < 10; i++) {
print("instance method : " + i);
}
}
private static void print(String message) {
System.out.println(message);
}
public static void main(String[] args) {
SynchronizedTest test1 = new SynchronizedTest();
SynchronizedTest test2 = new SynchronizedTest();
ExecutorService service = Executors.newFixedThreadPool(4);
service.execute(() -> {
test1.staticMethod();
});
service.execute(() -> {
test2.staticMethod();
});
}
}
从输出结果可以看到,此时两次调用时互斥的,即使调用的对象不同。这表明当synchronized关键字修饰静态方法的时候,不会受到实例对象的影响,也就是说此时synchronized锁住的是整个类,而不是某一个特定的实例,所以无论调用该方法的实例对象是否一致,都不会影响到方法执行的互斥性。
3.1.3 作用在代码块上,使用实例变量作为锁和使用静态变量作为锁
synchronized还可以作用在代码块上,这时候synchronized表现的行为就取决于括号后面的东西了!如果是实例变量,那么synchronized的表现和”作用在实例方法上“基本一致,即多线程同一对象调用多次方法会发生互斥,但如果是不同对象调用,那么就不会发生互斥。如果是静态变量,那么就和“作用在静态方法”上的表现基本一致。如下所示:
public void method() {
//作用在静态变量上
// synchronized (staticField) {
// for (int i = 0; i < 10; i++) {
// print("instance method : " + i);
// }
// }
//
//作用在实例变量上
synchronized (instanceField) {
for (int i = 0; i < 10; i++) {
print("instance method : " + i);
}
}
}
各位自行运行一下吧,不多说了。
3.2 解决问题
回到我们最开始的那个计数的问题,现在如果要采用synchronized关键字的方法,该如何修改代码呢?其实非常简单,至少有两种实现方式,分别是:
private static void increment(int delta) {
synchronized (Main.class) {
//Main.class是class对象,是静态的
count += delta;
}
}
和
private synchronized static void increment(int delta) {
count += delta;
}
此时,再运行程序,发现无论运行多少次,都能得到正确的结果100000,这说明经过改造后,我们已经消除了线程安全问题。
3.3 原子类
对于该问题来说,其实还有一种更加简便且性能更好的解决方案,即采用原子类保障共享变量,使其具备原子性。JDK1.5提供了大量的Atomic为前缀的原子类,例如AtomicInteger,AtomicLong,AtomicDouble等等,这些类提供了丰富的API供我们使用,这些API都是原子操作,即不可分割的操作,对于我们的计数问题,尤其合适。其底层大量使用Unsafe里的CAS(比较并交换)操作,CAS是一种无锁的同步操作,性能在多数情况下会优于有锁的同步。
下面修改一下原来的有线程安全问题的代码,使用AtomicInteger来包装count变量,如下所示:
private static AtomicInteger count = new AtomicInteger(0);
private static void increment(int delta) {
count.getAndAdd(delta);
}
//其他代码和之前完全一样,不需要修改
多运行几次,发现结果都是一样的,即100000,这表明这种解决方案对该问题是可行的。
4 不在线程之间共享变量来解决线程安全问题
这种方式其实有一个更加“专业”一点的名字:线程封闭。其实这种解决方案不算是解决问题,而是躲避问题!通过将变量限制在线程内部来避免其他线程访问变量,从而避免线程安全问题。常见的线程封闭有三种:Ad-hoc封闭,栈封闭和ThreadLocal封闭。
4.1 Ad-hoc封闭
这种方式完全是靠实现者自己实现的,非常脆弱,因为没有任何语言特性能将对象封闭到目标线程上。值得一提的是,Ad-hoc是拉丁文,意思是特定的、特别的等等意思,有时候这些个老外总是喜欢用这种花里胡哨的词语,让人摸不着头脑。
这种方式我也知之甚少,不敢多说了。
4.2 栈封闭
每个线程都有自己独立的栈,其他线程无法直接访问,基于这个特点,如果我们把一些有可能发生线程安全问题的对象“关”在栈内部,那么对该对象的访问肯定就是线程安全的了,是吧?一般我们都把这种变量叫做“局部变量”,即在方法内部声明的变量,每个方法其实都对应着一个栈帧,所以方法里声明的变量不会被其他线程的访问。
public class Snippet {
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals被封闭在方法中,不要使它们逸出!
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
}
这是《Java并发编程》一书中的例子,从例子中可以看到animals在方法内部声明,而且没有“逸出”,所以该变量仅该线程内部可见,所以该变量肯定是线程安全的。
那这种解决方案是否使用我们的计数问题呢?(即最开始提出的那个问题)答案是:可以!但会稍微有些麻烦,思路是在每个线程的执行方法里创建一个局部变量count,当循环完毕后,将count作为返回值返回(这不算是逸出,基本类型不存在逸出的问题,因为返回的只是一份拷贝),然后再将两个线程的返回结果合并即可,如下所示:
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<Integer> callable = () -> {
int count = 0;
for (int i = 0; i < 50000; i++) {
count++;
}
return count;
};
FutureTask<Integer> task1 = new FutureTask<>(callable);
FutureTask<Integer> task2 = new FutureTask<>(callable);
Thread thread1 = new Thread(task1);
Thread thread2 = new Thread(task2);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
int res = task1.get() + task2.get();
System.out.println(res);
}
}
无论运行多少次都会得到正确的答案100000,这说明这种方案是可行的。
4.3 ThreadLocal封闭
即利用ThreadLocal类来封装共享变量,每个线程只能拿到和自己有关的那份变量的拷贝。这样说可能有些不太好理解,不过当知道ThreadLocal的底层原理之后,就非常简单了。
下面是ThreadLocal的get方法源码:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
先获取当前线程实例,然后获取该实例的threadLocals字段值,该字段的类型ThreadLocalMap,该Map的键是ThreadLocal实例,值就是那个我们要包装的共享变量的值。ThreadLocal就是利用这个机制实现的对象封闭,借此来实现线程安全。同样,计数那个问题也能使用ThreadLocal来解决,而且比起栈封闭的更加简便。这里就不再贴代码了,各位可以自行编码实现。
5 将共享变量修改为不可变的变量
不可变对象具有天然的线程安全特性。换句话说,不可变对象即使被多个线程共享,也肯定是线程安全的,因为即使被多个线程共享,其值也不会被改变,而并发的读是完全没有问题的,不存在线程安全问题,而且不可变对象的性能较普通的对象好,JVM对不可变对象有一些特殊的处理,所以我认为将“共享变量修改成不可变的变量”这种方式是比较好的解决线程安全问题的方案。
关于如何构造一个不可变类,我在Java虚拟机(二):Java内存模型一文中有比较详细的介绍,在此不再赘述。
6 小结
本文先介绍了什么是线程安全,解决线程安全的三大类方式,接着就是围绕这三种解决方案,详细介绍了每种方案都有哪些常用的手段。需要注意的是,这三种解决方案并不都完全适用任何线程安全问题,最好的方法还是“先诊断,后下药”,由于线程安全问题往往都比较复杂,而且难以复现,所以诊断可能会有些困难,不过可以借助一下工具,例如JDK自带的jconsole,jstack,jstate等以及高级的类似Visual VM等工具来帮助发现问题,定位问题。
7 参考资料
《Java并发编程》