在前面的文章里,我们已经学会了如何创建线程,并且使用线程池技术管理他们。
在多线程环境中,还有一个非常关键的问题需要我们注意,那就是线程安全的问题。
当多个线程需要访问或修改同一个共享资源的时候,如果不能解决好线程安全问题,就很容易出现数据错乱,系统安全,甚至安全事故。
今天我们就一起来看一下并发编程中最核心的挑战--线程安全。
通过Java提供的最基础、最重要的synchronized关键字,感受一下如何在并发编程中解决线程安全问题。
一、老生常谈的案例
这个案例也算是经典了,我记得我学Java的时候就是用的这个案例。贴近生活,也容易理解。
假设银行账户账户里面有1000块钱,我们用两个线程同时从这个账户里各取800,看下会发生什么。
package com.lazy.snail.day37;
/**
* @ClassName BankAccount
* @Description TODO
* @Author lazysnail
* @Date 2025/7/28 11:18
* @Version 1.0
*/
public class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void withdraw(double amount) {
double currentBalance = this.balance;
if (currentBalance >= amount) {
try { Thread.sleep(10); } catch (InterruptedException e) {}
this.balance = currentBalance - amount;
System.out.println(Thread.currentThread().getName() + " 取款成功,当前余额: " + this.balance);
} else {
System.out.println(Thread.currentThread().getName() + " 余额不足!");
}
}
public double getBalance() {
return this.balance;
}
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount(1000);
Thread t1 = new Thread(() -> account.withdraw(800), "柜台A");
Thread t2 = new Thread(() -> account.withdraw(800), "柜台B");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("---------------------------------");
System.out.println("程序结束,最终账户余额: " + account.getBalance());
}
}
可以先不执行main方法,自己脑跑一下代码,思考一下会出现哪些情况的输出。
我们原本写这段代码的用意是希望其中一个线程取款成功,而另一个取款失败,最终的余额是200。
但是实际上,这段代码会跑出其他的结果。
结果一
第一种结果的运气很好,两个线程的操作完全串行化,没有发生致命的交错。
只有一个柜台成功取走了800块,另一个柜台取款失败,最终的账户余额是200块。这是我们喜闻乐见的结果。
类似于一个线程结束了整个操作,另一个线程才开始干活。
结果二
柜台A和柜台B几乎同时读取到账户余额是1000块。两个线程都通过了if(currentBalance >= amount)的判断。
各自sleep(10ms) 后,都执行了this.balance = currentBalance - amount,然后账户里的余额被第二个次覆盖成了200。
这个结果就出问题了,两个柜台都取出了800块,账户余额变成了200。无中生有,多产生了800块。
大致的画了一下代码执行的时序图,方便理解,T1~T8表示的是时间点。
balance = currentBalance - amount;这行代码,其实包括了读取-修改-写入三个步骤。
多线程的执行由CPU调度,顺序没办法预测,当两个线程的操作步骤发生交错的时候,可能就会发生我们想不到的结果。
这里有两个概念需要了解:
竞态条件:当多个线程的执行顺序会影响到程序的最终结果时,就发生了竞态条件。
原子性:一个或多个操作,要么全部执行成功,要么全部不执行,中间不能被任何其他线程干扰。
二、解决方案
为了解决上述案例中的问题,Java提供了synchronized关键字,它可以创建一个互斥锁,来保证共享资源代码的原子性。
synchronized可以理解成给方法或者代码块装上了一把锁。只有一个线程能拿到钥匙并进入,其他线程必须在门口排队等待,直到里面的线程出来并交还钥匙。
synchronized有三种用法:
用法一:修饰实例方法
我们直接在上面取款案例的代码中加上synchronized关键字:
public synchronized void withdraw(double amount) {
// ......
}
这个操作就是把当前BankAccount实例作为锁对象,只有一个线程能在某一时刻进入withdraw方法。
不管是柜台A先执行还是柜台B先执行:
第一个线程进入withdraw,检查balance >= 800成立,休眠、扣款、打印成功。
第二个线程等待,等第一个线程退出后进入withdraw,这个时候balance == 200,不足800,打印余额不足。
所以最终输出一定是一个成功,一个失败,余额是200。
给实例方法加上synchronized,类似于synchronized(this),理解为给当前实例对象上了一把锁。
如果把线程调用的代码写成下面这样:
Thread t1 = new Thread(() -> new BankAccount(1000).withdraw(800));
Thread t2 = new Thread(() -> new BankAccount(1000).withdraw(800));
那就起不到效果了,因为两个线程访问的是不同对象,即使方法上加上了synchronized,也不是同一把锁。
用法二:修饰静态方法
如果用synchronized来修饰一个静态方法,它同样会为这个方法加上锁,但锁的对象跟实例方法不同。
他锁的是当前类的Class对象。
package com.lazy.snail.day37;
/**
* @ClassName SynchronizedTest
* @Description TODO
* @Author lazysnail
* @Date 2025/7/28 16:18
* @Version 1.0
*/
public class SynchronizedTest implements Runnable {
static SynchronizedTest instance1 = new SynchronizedTest();
static SynchronizedTest instance2 = new SynchronizedTest();
@Override
public void run() {
syncMethod();
}
public static synchronized void syncMethod() {
System.out.println(Thread.currentThread().getName() + "===begin");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "===end");
}
public static void main(String[] args) {
Thread t1 = new Thread(instance1, "线程A (持有instance1)");
Thread t2 = new Thread(instance2, "线程B (持有instance2)");
t1.start();
t2.start();
}
}
在static修饰的方法签名中加上synchronized关键字。
案例中创建了两个不同的实例,instance1和instance2,让两个线程分别持有不同的实例。
从输出结果来看,即使t1和t2关联的是不同的实例对象(instance1和instance2),它们在调用syncMethod()的时候竞争的是同一把锁。
这把锁不属于任何一个实例,而是属于SynchronizedTest这个类本身,SynchronizedTest.class对象。
用法三:修饰代码块
这是最灵活、也是最推荐的用法。可以精确地指定需要同步的代码范围,还能明确地选择用哪个对象作为锁。
package com.lazy.snail.day37;
/**
* @ClassName BankAccount2
* @Description TODO
* @Author lazysnail
* @Date 2025/7/28 16:32
* @Version 1.0
*/
public class BankAccount2 {
private double balance;
private final Object lock = new Object();
public void withdraw(double amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
}
}
}
}
示例代码中创建了一个私有的、final的对象作为锁。
任何线程只有拿到这个对象的锁,才能进入到synchronized代码块中。否则就只能在外面等着。
这种锁只锁定真正需要保护的代码,而不是整个方法。减小了锁的粒度。可以提高代码的并发性能。
因为除了需要操作临界区资源的代码被锁住,其他线程完全可以跑方法中临界区以外的代码。
而不是大家都被拦在方法外面。
三、synchronized的优化
在Java1.6之前,synchronized是重量级的锁,每个锁都直接关联到重量级操作系统互斥量。
线程阻塞和唤醒都涉及系统调用,开销很大。
特别是在Java1.5中引入了ReentrantLock等Lock接口的并发工具类,让用户可以精细控制锁行为。
那个时期,synchronized一致被认为又粗又笨又慢。完全被ReentrantLock碾压。
那时候大家都建议不要使用synchronized。
后来Java1.6中,完成了对synchronized的大改进:
所有锁优化的信息(锁状态、持有锁的线程ID等)都存储在Java对象的对象头中的一个叫Mark Word的区域里。
下面大致的描述无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁的路径:
无锁:
一个对象刚被创建时候,不包含任何锁信息。这个时候它的Mark Word里存储的是对象的哈希码、GC分代年龄等。
偏向锁:
主要是为了优化同一个线程多次获取同一个锁的场景,消除不必要的同步开销。当线程A第一次获取锁时,JVM会使用CAS操作把该线程的ID记录在对象的Mark Word里,并将锁状态标记成偏向锁。
当线程A再次尝试获取这个锁的时候,他只需要检查Mark Word里的线程ID是不是自己,如果是,就不需要任何同步操作(无需CAS)就可以直接获取锁了,提高了效率。
如果有另一个线程B尝试获取这个已经被偏向的锁,偏向模式就结束了。JVM会撤销偏向锁,把锁升级成轻量级锁。当然撤销偏向锁本身肯定也是有一点开销的。
轻量级锁:
轻量级锁的目的主要是当系统中存在少量、短时间的线程竞争时,避免让线程进入阻塞状态。因为线程阻塞和唤醒的开销很大。
线程在自己的线程栈里创建一个名叫锁记录的空间。使用CAS操作,尝试把对象的Mark Word更新成指向这个锁记录的指针。
如果更新成功,这个线程就获取了锁。如果更新失败,说明存在竞争。这个线程不会立马挂起,而是会进行自旋——执行一个空的循环,短暂地等待锁被释放,并这段时间不断尝试CAS获取锁。
JVM会根据上次自旋的成功率和锁的持有者状态,来动态决定自旋的次数,而不是固定次数。
如果自旋超过了一定的次数,或者有其他线程也在自旋等待,JVM就会认为竞争比较激烈,从而把轻量级锁升级成重量级锁。
重量级锁 :
重量级锁就是最传统的实现方式。它依赖于操作系统的互斥量来实现。
当线程获取锁失败后,他就会被挂起(阻塞),进入等待队列,并放弃CPU。直到锁被释放后,它才会被操作系统唤醒并重新参与竞争。
虽然重量级锁不会像自旋那样消耗CPU,但是线程的阻塞和唤醒都涉及用户态和内核态的切换,是系统调用,开销很大。
四、synchronized在JDK中的应用
synchronized在JDK源码中有很多的应用,下面找两个比较典型的看一下。
StringBuffer和StringBuilder
StringBuffer和StringBuilder是早期的高频面试题中必问的。
StringBuffer是JDK1.0的类,在设计的时候就考虑了多线程安全。
而StringBuilder是JDK1.5引入的,API跟StringBuffer完全兼容,只是去掉了线程安全保障来换取更高的性能。
可以看到,StringBuffer的append方法被synchronized关键字修饰。
这意味着每次调用append时,当前StringBuffer实例(this)都会被锁定。
这就保证了在多线程环境下,字符串拼接操作不会被打断,是线程安全的。
StringBuilder中完全没有synchronized关键字。
任何线程都可以随时调用,没有加锁和解锁的开销。
这就让它在单线程环境下速度更快,但如果多个线程共享同一个StringBuilder实例并同时修改,就会导致数据错乱,是非线程安全的。
Vector和ArrayList
Vector是早期线程安全的动态数组实现,所有方法都用synchronized。也是JDK1.0开始就有的。
ArrayList是JDK1.2新增的,是作为Vector的非同步替代品。
Vector中的方法被synchronized修饰,锁住的是Vector实例。
虽然保证了线程安全,但也意味着每次只有一个线程能操作这个Vector,并发性能极差。
Vector已经基本上被视为遗留类了 ,开发过程中基本不会用到,也不建议使用。在需要线程安全的List的时候,我们会使用Collections工具类来包装一个ArrayList。 List list = new ArrayList<>(); List safeList = Collections.synchronizedList(list);
结语
本文中提到了各种锁、还有对象头、Mark Word、CAS等各种概念或者技术术语。
乍看让人晕头转向,对于无法理解或者没有接触过的可以暂时跳过。
主要掌握什么情况下会引发线程安全问题,以及synchronized的几种用法。
对于synchronized的原理和实现,以及其他的概念,将在后续的文章中提及(或者另外的系列详细讲)。
下一篇预告
Day38 | Java中更灵活的锁ReentrantLock
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多文章请关注我的公众号《懒惰蜗牛工坊》