一、基本概念

1.1 定义

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

1.2 无状态对象

如果只是看上面的定义是无法弄清楚什么是线程安全的,因此我们需要举一些例子来进行说明,就比如说Java Web开发中经常使用的Servlet,一般来说Servlet是单例的(也可以是多例的,但这里我们只讨论单例的情况), 实例中的方法会被多个线程进行调用,因此Servlet要保证其是线程安全的。所以Servlet通常都被设计成无状态的,下面给出一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class StatelessServlet implments Servlet {
// ...

@Override
public void service(ServletRequest req, ServletResponse resp) {
int i = handleReq(req);
sendResponse(i);
}

// ...
}

与大多数Servlet相同,StatelessServlet是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问该Servlet的线程不会影响另一个访问同一个Servlet的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

无状态对象一定是线程安全的

大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

1.3 实例:多窗口售票

假设有一个公园,该公园每天卖100张票,共有3个窗口进行售票,现在需要写一个程序来模拟售票的过程。下面就来实现该需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Ticket implements Runnable {
private int ticket = 100;

@Override
public void run() {
while (true) {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "进行售票: " + ticket--);
} else {
break;
}
}
}
}

我们创建了一个Ticket类实现Runnable接口,接下来我们来编写测试类来进行测试:

1
2
3
4
5
6
7
8
9
public class SyncDemo {
public static void main(String[] args) throws InterruptedException {
Ticket ticket = new Ticket();
for (int i = 0; i < 3; i++) {
Thread t = new Thread(ticket);
t.start();
}
}
}

既然写好了测试类,那我们就来进行测试。结果却出现了下面的情况:

pic1.png

  • 注:为了更容易观察出这种结果可以让每个线程执行完ticket--后休眠一段时间。

通过控制台的输出我们可以轻易的观察到不同窗口(线程)竟然出现售出同一张票的情况,这显然是不符合预期的,那么该如何解决这种问题呢?

二、原子性

2.1 竟态条件

在上面多窗口售票的例子中,之所以会出现多个线程售出同一张的情况,原因就在于ticket--虽然看起来只是一个操作,但这个操作是非原子性的,因此并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取ticket的值,将值减1,然后将计算结果写入ticket。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。

因此在多窗口售票的例子中,在某种情况下,每个线程读到的值都是同一个值,接着执行递减操作,并将ticket的值设为原先的值减1,结果就出现不同窗口(线程)售出同一张票的情况。

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:**竞态条件(Race Condition)**。

2.2 实例:错误的单例模式实现

在很多情况下需要某个类是单例的,下面给出单例模式的一种实现:

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

实际上上面的实现是错误的,在getInstance方法中存在竟态条件:假设在A、B线程中同时调用该方法,A判断instance为空,因此实例化一个Singleton对象;而B也判断instance为空,因此又实例化一个Singleton对象,这显然违背了单例模式的要求。

三、加锁机制

多窗口售票和单例模式实现的例子中都包含一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。如果多窗口售票中的ticket--是原子操作的话,那么竟态条件就不会发生,换句话说就是“读取-修改-写入”这个复合操作要么全部不执行,要么全部执行。
Java的内置锁相当于一种互斥体(互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块

3.1 synchronized

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字_synchronized_来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象静态的_synchronized_方法以Class对象作为锁

1
2
3
synchronized(lock) {
// 访问或修改由锁保护的共享状态
}

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进人同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java支持通过同步代码块和同步方法的形式进行加锁,下面分别介绍这两种方式:

3.1.1 同步代码块

代码格式如下:

1
2
3
synchronized(lock) {
// 访问或修改由锁保护的共享状态
}

如果同步代码块处于非静态方法内,则可以选择当前类本身(this)或当前类中任意类型的类对象作为锁;如果如果同步代码块处于静态方法内,则可以选择当前类的Class对象作为锁。

3.1.2 同步方法

代码格式如下:

1
2
3
public synchronized void func() {
// 访问或修改由锁保护的共享状态
}

同步方法本质上是选择当前类的Class对象作为锁。

3.2 重入

3.2.1 概念

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”

3.2.2 原理

重入的一种实现方法是为每个锁关联一个获取计数器值和一个所有者线程。当计数器值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数器值置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减,当计数值为0时,这个锁将被释放。

3.2.3 为什么使用可重入锁

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。例如下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
public class Parent {
public synchronized void doSomething() {
// ...
}
}

public class Child extends Parent {
public synchronized void doSomething() {
// ...
super.doSomething();
}
}

如果没有可重入锁,那么这段代码将产生死锁。因为 Parent 和 Child 中 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Parent 上的锁,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获取 Parent 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。

3.3 实例:线程安全的多窗口售票

我们使用内置锁对上面的代码进行改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Ticket implements Runnable {
private int ticket = 100;

@Override
public void run() {
while (true) {
synchronized (this) {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "进行售票: " + ticket--);
} else {
break;
}
}
}
}
}

再次运行程序,可以看到控制台已经输出了正确的结果。

3.4 实例:同步的单例模式实现

只需将 getInstance 方法改为同步方法即可:

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

3.5 实现原理

我们通过一段代码来分析其实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
}

我们先使用javac编译器对其进行编译,随后使用javap -v命令对编译后的字节码文件进行反编译,结果如下:

pic2.png

注意到monitorentermonitorexit就是进入和退出同步锁(监视器锁)的指令,这也是 synchronized 的实现原理。

3.6 性能

使用Java的内置锁虽然可以解决线程安全问题,但实际上这是一种很低效的方式,因为同一时刻只能有一个线程能持有锁并执行任务。因此千万不要滥用内置锁,这可能会带来非常严重的性能问题,同时尽量在可能出现线程安全问题的位置使用同步代码块,而不是简单的使用同步方法。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能

通常,在简单与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)

当使用锁时,要清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。

注:当执行时间较长的计算或者可能无法快速完成的操作时(例如网络I/O或控制台I/O),一定不要持有锁。