本文发布于专栏Effective Java,如果您觉得看完之后对你有所帮助,欢迎订阅本专栏,也欢迎您将本专栏分享给您身边的工程师同学。
很多时候,我们的代码,在单线程的环境下是可以运行的非常完美,然而,一旦把代码放到多线程的环境下去接受蹂躏,结果常常是惨不忍睹的。
《Java并发编程实践》中,给出了线程安全性的解释:
A class is thread-safe when it continues to behave correctly when accessed from multiple threads.
当一个类,不断被多个线程调用,仍能表现出正确的行为时,那它就是线程安全的。
这里的关键在于对“正确的行为”的理解,什么意思呢?多写几个线程不安全的代码你就明白了。
假设我们需要给Servlet增加一个统计请求数的功能,于是我们使用了一个long变量作为计数器,并在每次请求时都给这个计数器加一(本文的所有代码,可到Github下载):
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
++count;
// To something else...
}
}
在单线程的环境下,这份代码绝对正确,然而,当有多个线程同时访问时,问题就暴露了。
关键就在于++count,它看上去只是一个操作,实际上包含了三个动作:
1. 读取count
2. 将count加一
3. 将count的值到内存中
这是一个“读取-修改-写入”的操作序列,因此假设现在count是9,然后:
1. 线程A进入service方法,读到count值是9
2. 在A修改完count的值但是还没写入内存之前,线程B也进入service方法,并且读取了count值,这时候线程B读取到的count还是9
3. 最后,两个线程都对值为9的count,进行了加一的操作,两次请求下来,计数器只增加了一次。
显然,这个类,在多线程的环境下,没有表现出我们预期的行为,所以称它为线程不安全。
这一次,我们需要写一个单例,单例很简单呀,不就是构造函数私有化么:
public class UnsafeSingleton {
private static UnsafeSingleton instance = null;
private UnsafeSingleton() {
}
public static UnsafeSingleton getInstance() {
if (instance == null)
instance = new UnsafeSingleton();
return instance;
}
}
如果只有一个线程调用我们的代码,那这个类,永远不会生出二胎。但是,放在多线程的环境下,它就可能会意外怀孕了:
预期中的计划生育失败,我们再一次写出了线程不安全的代码。
如果说前面两种破坏方式都太过明显,很难在代码review中逃过法眼的话,接下来这种方式,就显得非常高级了。
public class ThisEscape {
private final List listOfEvents;
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
listOfEvents = new ArrayList();
}
void doSomething(Event e) {
listOfEvents.add(e);
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {
}
}
这个类的构造函数接收了一个事件源,在构造函数中,会给事件源添加一个监听器。咋看之下,你也许不会发现这段代码有什么问题,其实这里面暗藏着NullPointerException:
这一切的根源都在于,ThisEscape的构造函数,在ThisEscape还没实例化完成之前,就把this对象泄漏出去,使得外部可以调用实例对象的方法,这就像还没开考,就把考题给公布出去了,因此称之为,考题泄漏。
《Java并发编程实践》将这种误把对象发布出去的行为,称为对象逸出(Escape)。
对象逸出是指不想发布对象,却不小心发布了。还有一种是,想发布对象,却在对象还没制造好之前,就给了对方使用半成品的机会:
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
很难想象,什么情况下n != n会成立,并抛出异常。大家可以先参考StackOverflow里的解释,主要是涉及到Java的指令重排,后面会给大家详细讲解。
这篇文章给大家解释了什么是线程安全,并且举了四个线程不安全的例子来加深大家对线程安全的理解:消失的请求数、意外怀孕、考题泄漏、半成品。这四个例子,分别对应三种常见的线程不安全情形:
绝大多数的线程不安全问题,都可以归结为这三种情形。而这三种情形,其实又可以再缩减为两种:对象创建时和对象创建后。不仅仅是在对象创建后的业务逻辑中要考虑线程的安全性,在对象创建的过程中,也要考虑线程安全。
这篇文章里只是解释了为什么这些代码会有线程安全问题,并没有跟大家说如何对代码进行修改,使之成为“线程安全”,我会在后面的文章中和大家一起详细探讨。
有人可能会说,线程安全嘛,加同步锁不就可以啦,其实不然,光光同步锁,就有很多可以探究的了:
更何况,解决并发问题,也绝对不是加锁这么简单,我们还需要了解:
再者,解决了线程安全,我们还需要考虑线程的生命周期管理、线程使用的性能问题等:
乃至我们学习Java并发编程最最初始的问题:
这些,都是我新的一年里要和大家一起分享的,分享的内容主要基于《Java并发编程实践》里提到的知识,我买了中文版和英文版。这是一本很难啃的书,我会一如既往的用通俗易懂的语言来和大家分享我的学习心得。