Day37 | 线程安全与synchronized

32 阅读11分钟

在前面的文章里,我们已经学会了如何创建线程,并且使用线程池技术管理他们。

在多线程环境中,还有一个非常关键的问题需要我们注意,那就是线程安全的问题。

当多个线程需要访问或修改同一个共享资源的时候,如果不能解决好线程安全问题,就很容易出现数据错乱,系统安全,甚至安全事故。

今天我们就一起来看一下并发编程中最核心的挑战--线程安全。

通过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

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》