线程同步
1. 竞态条件
假设两个线程同时执行指令 accounts[to] += amound;
,问题在于这不是原子操作,这个指令可能如下处理:
- 将
acctous[to]
加载到寄存器 - 增加
amount
- 将结果写回
accounts[to]
现在,假定第1个线程执行步骤1和2,然后它的运行权被抢占。再假设第2个线程被唤醒,更新 account
数组中的同一个元素。然后,第1个线程被唤醒并完成其第3步,这个动作会抹去第2个线程所做的更新,这样一来总金额就不再正确了。如下图所示:

1.1 线程同步的典型问题
线程同步的典型问题即是“生产者-消费者”问题:
- 假设有一个线程负责往数据区写数据,另一个线程从同一数据区中取数据,两个线程可以并行执行。
- 如果数据区已满,生产者要等消费者取走一些数据后才能再写。
- 如果数据区已空,消费者要等生产者写入一些数据后才能再取。
具体而言,比如经典的银行取钱问题:
// 定义一个账户类
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
class Account {
private String accountNo;
private double balance; // balance:余额
// 下面根据accountNo来重写hashCode()和equals()方法
public int hashCode() {
return accountNo.hashCode();
}
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj != null && obj.getClass() == Account.class) {
Account target = (Account) obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}
// 定义一个取钱的线程类
class DrawThread extends Thread {
private Account account;
private double drawAmount;
public DrawThread(String name, Account account, double drawAmount) {
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
// 当多个线程修改同一个共享数据时,将涉及数据安全问题
public void run() {
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount) {
// 吐出钞票
System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);
/*
* // 取消掉这里的注释代码也依然是错误的程序
* try { Thread.sleep(1); } catch (InterruptedException ex) {
* ex.printStackTrace(); }
*/
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为:" + account.getBalance());
} else {
System.out.println(getName() + "取钱失败!余额不足!");
}
}
}
// 模拟取钱过程
class DrawTest {
public static void main(String[] args) {
Account acct = new Account("1234567", 1000);
// 模拟两个线程对同一个账户取钱
new DrawThread("甲", acct, 800).start();
new DrawThread("乙", acct, 800).start();
// -----------结果如下,总是出错-----------:
// 乙取钱成功!吐出钞票:800.0
// 甲取钱成功!吐出钞票:800.0
// 余额为:200.0
// 余额为:-600.0
}
}
1.2 Java解决方案
有两种机制可防止并发访问代码块:
- Java 语言提供了一个
synchronized
关键字来达到这一目的。 - Java 还可以通过显式定义同步锁对象来实现同步,在这种机制下,同步锁由
Lock
对象充当:Lock
提供了比synchronized
方法和synchronized
代码块更广泛的锁定操作,其允许实现更灵活的结构,可以具有差别很大的属性,并且支持多个相关的Condition
对象。Lock
是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock
对象加锁,线程开始访问共享资源之前应先获得Lock
对象。Lock
是一个接口,通常使用的是 Java 5 引入的其实现类ReentrantLock
。
2. 同步锁
Lock, ReadWriteLock
是两个根接口,Java 为 Lock
提供了 ReentrantLock
实现类,为 ReadWriteLock
提供了 ReentrantReadWriteLock
实现类。
ReentrantLock
称为重入锁,因为线程可以反复获得已拥有的锁,锁有一个持有计数来跟踪对lock
方法的嵌套调用,线程每一次调用lock
后都要调用unlock
来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。ReentrantReadWriteLock
为读写操作提供了三种锁模式:Writing, ReadingOptimistic, Reading。
Java 8 新增了 StampedLock
类,在大多数场景中它可以替代传统的 ReentrantReadWriteLock
。
2.1 锁的使用语法
在实现线程安全的控制中,比较常用的是 ReentrantLock
,该 Lock
对象可以显式地加锁、释放锁,其一般的代码格式为:
class X {
// 定义锁对象
private final ReentrantLock lock = new ReentrantLock();
// ...
// 定义需要保证线程安全的方法
public void method() {
lock.lock(); // 加锁
try {
// 需要保证线程安全的代码
} finally {
// 使用finally块来保证释放锁
lock.unlock();
}
}
}
使用锁时,就不能使用 try-with-resources
语句。
2.2 Lock 接口
方法 | 说明 |
---|---|
void lock() | 获得锁(如果锁当前被另一个线程占有,则阻塞) |
void unlock() | 释放锁 |
boolean tryLock(); | |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; | |
void lockInterruptibly() throws InterruptedException; | |
Condition newCondition(); | 返回一个与这个锁相关联的条件对象 |
2.3 ReentrantLock 类
方法 | 说明 |
---|---|
ReentrantLock() | 构造一个重入锁,可以用来保护临界区 |
ReentrantLock(boolean fair) | 构造一个采用公平策略的锁,一个公平锁倾向于等待时间最长的线程。 不过,这种公平保证可能严重影响性能,所以默认情况下不要求锁是公平的。 |
听起来公平锁很不错,但是公平锁要比常规锁慢很多。即使使用公平锁,也无法确保线程调度器是公平的,如果线程调度器选择忽略一个已经为锁等待很长时间的线程,它就没有机会得到公平处理。
2.4 使用示例
public class Bank {
private var bankLock = new ReentrantLock();
// ...
public void transfer(int from, int to, int amount) {
bankLock.lock();
try {
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
} finally {
bankLock.unlock();
}
}
}
注意每个 Bank
对象都有自己的 ReentrantLock
对象,如果两个线程试图访问同一个 Bank
对象,那么锁可以用来保证串行化访问。不过,如果两个线程访问不同的 Bank
对象,每个线程会得到不同的锁对象,两个线程都不会阻塞(本该如此,因为线程在操纵不同的 Bank
实例时,线程之间不会相互影响)。

3. 条件对象
3.1 背景引入
通常,线程进入临界区后却发现只有满足了某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程(由于历史原因,条件对象经常被称为条件变量)。
以前面的银行模拟程序为例,假设现在有这样一个需求,如果一个账户没有足够的资金转账,我们不希望从这样的账户转出资金。注意这种情况下不允许使用如下代码:
if (bank.getBalance(from) >= amount) {
// 线程通过条件后,也有可能在调用transfer方法之前被中断,在线程再次运行前,账户余额可能已经低于提款金额,条件不再满足
bank.transfer(from, to, amount);
}
此时,必须确保在检查余额与转账活动之间没有其他线程修改余额,为此可以使用一个锁来保护这个测试和转账操作:
public void transfer(int from, int to, int amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
// wait
...
}
// 转账...
} finally {
bankLock.unlock();
}
}
现在,对于上述代码,当账户中没有足够的资金时,线程要等待,直到另一个线程向账户中增加了资金,然而这个线程刚刚获得了对 bankLock
的排他性访问,因此别的线程没有存款的机会。由此,这里就要入条件对象。
3.2 使用介绍
一个锁对象可以有一个或多个相关联的条件对象。可以通过锁对象的 newCondition
方法获得一个条件对象,习惯上会给每个条件对象一个合适的名字来反映它表示的条件。例如:
class Bank {
private Condition sufficientFunds;
// ...
public Bank() {
// ...
sufficientFunds = bankLock.newCondition();
}
}
如果 transfer
方法发现资金不足,它会调用 sufficientFunds.await();
,从而当前线程现在暂停,并放弃锁。这就允许另一个线程执行了,我们希望那个线程能增加账户余额。
注意,等待获得锁的线程和已经调用了 await
方法的线程存在本质上的不同。一旦一个线程调用了 await
方法,它就进入这个条件的等待集(wait set),当锁可用时,该线程并不会变为可运行状态,而是直到另一个线程在同一条件上调用 signalAll
方法。当另一个线程完成转账时,它应该调用 sufficientFunds.signalAll();
,这个调用会重新激活等待这个条件的所有线程,然后某个线程便可以从 await
调用中返回,得到这个锁,并从之前暂停的地方继续执行。
对于 signalAll
方法的调用时机,从经验上讲,只要有一个对象的状态有变化,而且可能有利于等待的线程,就可以调用 signalAll
。例如,在上述例子中,应该在完成转账时即调用 signalAll
方法:
public void transfer(int from, int to, int amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
sufficientFunds.await();
}
// 转账...
sufficientFunds.signalAll();
} finally {
bankLock.unlock();
}
}
3.3 Condition 类的方法
wait
,notifyAll
,notify
方法是Object
类的final
方法,Condition
方法必须命名为await
,signalAll
,signal
,从而不会与那些方法发和冲突。
方法 | 说明 |
---|---|
void await() | 将该线程放在这个条件的等待集中 |
void signalAll() | 解除该条件等待集中所有线程的阻塞状态 |
void signal() | 从该条件的等待集中随机选择一个线程,解除其阻塞状态 |
4. synchronized
4.1 介绍
Lock
和 Condition
接口允许程序员充分控制锁定。不过,大多数情况下,你并不需要那样控制,完全可以使用 Java 语言内置的一种机制。从1.0版开始,Java 中的每个对象都有一个内部锁。如果一个方法声明时有 synchronized
关键字,那么对象的锁将保护整个方法,也就是说,要调用这个方法,线程必须获得内部对象锁。
换句话说,以下两者等价:
使用
synchronized
关键字的:public synchronized void method() { // method body }
使用
Lock
与Condition
的:public void method() { this.instrinsicLock.lock(); try { // method body } finally { this.instrinsicLock.unlock(); } }
核心来讲,synchronized
为线程同步关键字,用来实现互斥。
4.2 同步监视器与同步代码块
当有两个进程并发修改同一个文件时就有可能造成异常。为了解决同步问题,Java 的多线程引入了同步监视器,而使用同步监视器的通用方法就是同步代码块。同步代码块的语法为:
synchronized(obj) {
// ……
// 此处的代码就是同步代码块
}
- 上边的
obj
就是同步监视器,线程开始执行同步代码块之前,必须先获得对同步监视器的锁定; - 任何时刻只可能有一个线程获得对同步监视器的锁定,当同步代码块执行完成后,该线程就会释放对同步监视器的锁定;
- 对于同步代码块,要看清楚哪个对象已经用于锁定。要注意,在同一个对象上进行同步的线程将彼此阻塞,在不同对象上锁定的线程将永远不会彼此阻塞。
虽然 Java 程序允许使用任何对象作为同步监视器,但通常还是应该使用可能被并发访问的共享资源充当同步监视器。
class DrawThread extends Thread {
private Account account;
private double drawAmount;
public DrawThread(String name, Account account, double drawAmount) {
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
// 当多个线程修改同一个共享数据时,将涉及数据安全问题
public void run() {
// 使用account作为同步监视器,任何线程进入下面同步代码块之前
// 必须先获得对account账户的锁定
// 这种做法符合:“加锁——修改——释放锁”的逻辑
synchronized (account) {
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount) {
// 吐出钞票
System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);
// try {
// Thread.sleep(1);
// } catch (InterruptedException ex) {
// ex.printStackTrace();
// }
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为:" + account.getBalance());
} else {
System.out.println(getName() + "取钱失败!余额不足!");
}
}
// 同步代码块结束,该线程释放同步锁
}
}
4.3 同步方法
与同步代码块对应,Java 的多线程安全支持还提供了同步方法,同步方法就是用 synchronized
关键字来修饰的方法。
- 对于
synchronized
修饰的实例方法,无须显式指定同步监视器,同步方法的同步监视器就是this
,即调用该方法的对象。 - 对于
synchronized
修饰的静态方法,它会获得相关类对象(Xxx.class
对象)的内部锁。
下面把 Account
类对 balance
的访问设置成线程安全的,只要把修改 balance
的方法变成同步方法即可:
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
class Account {
private String accountNo;
@Setter(AccessLevel.NONE)
private double balance;
// 提供一个线程安全的draw()方法来完成取钱操作
public synchronized void draw(double drawAmount) {
// 账户余额大于取钱数目
if (balance >= drawAmount) {
// 吐出钞票
System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
// 修改余额
balance -= drawAmount;
System.out.println("\t余额为:" + balance);
} else {
System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
}
}
// 下面根据accountNo来重写hashCode()和equals()方法
// ...
}
4.4 同步监视器的释放
任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?实际上,程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下进行释放:
- 当前线程的同步代码块、同步方法执行结束:
- 正常结束;
- 遇到
break, return
等结束; - 抛出异常。
- 当前线程执行同步代码块/同步方法时,程序执行了同步监视器对象的
wait()
方法,则当前线程暂停,并释放同步监视器。
值得注意的是,以下情况线程不会释放同步监视器:
- 线程执行同步代码块/同步方法时,程序调用
Thread.sleep()
、Thread.yield()
方法来暂停当前线程的执行,当前线程不会释放同步监视器(线程睡眠时,它所持的任何锁都不会释放); - 线程执行同步代码块时,其他线程调用了该线程的
suspend()
方法将该线程挂起,该线程不会释放同步监视器。
4.5 效率
可变类的线程安全是以降低程序的运行效率为代价的,为了减少线程安全带来的负面影响,程序可以采用如下策略:
- 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步;
- 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本,在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。
5. 死锁
线程在运行过程中,其中某个步骤往往需要满足一些条件才能继续进行下去,如果这个条件不能满足,线程将在这个步骤上出现阻塞。例如,线程 A 可能会陷于对线程 B 的等待,而线程 B 同样陷于对线程 C 的等待,依次类推,整个等待链最后又可能回到线程 A,如此一来便陷入一个彼此等待的轮回中,任何线程都动弹不得,此即所谓死锁(deadlock)。
对于死锁问题,关键不在于出现问题后调试,而在于预防!
当两个线程相互等待对方释放同步监视器时就会发生死锁,一旦出现死锁,程序不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
class A {
public synchronized void foo(B b) {
System.out.println("当前线程名:" + Thread.currentThread().getName() + " 进入了A实例的foo()方法");
try {
Thread.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() + " 企图调用B实例的last()方法");
b.last();
}
public synchronized void last() {
System.out.println("进入了A类的last()方法内部");
}
}
class B {
public synchronized void bar(A a) {
System.out.println("当前线程名:" + Thread.currentThread().getName() + " 进入了B实例的bar()方法");
try {
Thread.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() + " 企图调用A实例的last()方法");
a.last();
}
public synchronized void last() {
System.out.println("进入了B类的last()方法内部");
}
}
class DeadLock implements Runnable {
A a = new A();
B b = new B();
public void init() {
Thread.currentThread().setName("主线程");
a.foo(b);
System.out.println("进入了主线程之后");
}
public void run() {
Thread.currentThread().setName("副线程");
b.bar(a);
System.out.println("进入了副线程之后");
}
public static void main(String[] args) {
DeadLock dl = new DeadLock();
new Thread(dl).start();
dl.init();
}
}
6. volatile 字段
volatile
关键字为实例字段的同步访问提供了一种免锁机制。如果一个字段声明为 volatile
,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新。
以后再说吧......
7. final 字段
还有一种情况可以安全地访问一个共享字段,即这个字段声明为 final
时。
书中的例子看上去不太合适,以后再说吧......
8. 线程局部变量
有时可能要避免共享变量,此时可以使用 ThreadLocal
辅助类为各个线程提供各自的实例。
ThreadLocal
类,是 Thread Local Variable(线程局部变量)的意思,ThreadLocal
为每一个使用该变量的线程都提供一个变量值的副本,使每一个线程都可以独立地改变自己的副本,而不会和其他线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量一样。
ThreadLocal<T>
类主要有如下方法:
方法 | 说明 |
---|---|
T get() | 得到这个线程的当前值。如果是首次调用 get ,会调用 initialize 来得到这个值。 |
void set(T value) | 为这个线程设置一个新值 |
void remove() | 删除对应这个线程的值 |
static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) | 创建一个线程局部变量,其初始值通过调用给定的提供者生成 |
ThreadLocal
提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的整个变量封装进 ThreadLocal
,或者把该对象与线程相关的状态使用 ThreadLocal
保存;
ThreadLocal
是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争。
class Account {
private ThreadLocal<String> name = new ThreadLocal<>();
public Account(String str) {
this.name.set(str);
System.out.println("---" + this.name.get());
}
// name的setter, getter方法
public String getName() {
return name.get();
}
public void setName(String str) {
this.name.set(str);
}
}
class MyTest extends Thread {
private Account account;
public MyTest(Account account, String name) {
super(name);
this.account = account;
}
public void run() {
for (int i = 0; i < 10; ++i) {
if (i == 6)
account.setName(getName());
System.out.println(account.getName() + " 账户的i值:" + i);
}
}
}
class ThreadLocalTest {
public static void main(String[] args) {
/*
* 虽然两个线程共享同一个账户,即只有一个账户名
* 但由于账户名是ThreadLocal类型的,所以每个线程都完全拥有各自的副本
* 故在i == 6之后,将看到两个线程访问同一个账户时出现不同的账户名
*/
Account at = new Account("初始名");
new MyTest(at, "线程甲").start();
new MyTest(at, "线程已").start();
}
}