跳到正文
hello world

9. synchronized:先用最直接的方式解决线程安全

发布于阅读量 0

9. synchronized:先用最直接的方式解决线程安全

上一节我用 count++ 看到了线程安全问题。

两个线程各自执行 100000 次自增,按理说结果应该是 200000,但实际结果经常对不上。原因也说过了,count++ 不是一个不可拆分的操作,它里面至少包含:

读取 count;
加 1;
写回 count。

多个线程同时执行时,中间步骤可能交叉,最终就会丢失更新。

这一节先用最直接的方式解决它:synchronized


先把问题代码再看一眼

原来的不安全代码大概是这样:

package com.succos.thread;

public class ThreadUnsafeDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        }, "thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("最终结果:" + count);
    }
}

问题就在这里:

count++;

这行代码会被两个线程同时执行。

如果我想让结果稳定变成 200000,就要保证同一时刻只有一个线程能执行这段自增逻辑。

这就是 synchronized 要做的事。


用 synchronized 方法解决

新建类:

com.succos.thread.SynchronizedDemo

代码如下:

package com.succos.thread;

public class SynchronizedDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("最终结果:" + count);
    }

    private static synchronized void increment() {
        count++;
    }
}

这次再运行,结果基本就会稳定输出:

最终结果:200000

关键变化是这里:

private static synchronized void increment() {
    count++;
}

我把 count++ 放进了一个 synchronized 方法里。

这样同一时间只能有一个线程进入 increment() 方法。


synchronized 保护的是代码,不是变量

这个地方我觉得很容易理解偏。

count 这个变量本身并没有被“锁住”。

真正被保护的是这段代码:

count++;

也就是说,synchronized 的作用不是给变量加锁,而是让某段代码同一时间只能被一个线程执行。

可以这样理解:

没有 synchronized:
thread-1 和 thread-2 可以同时执行 count++;

加了 synchronized:
thread-1 进入 increment() 后,thread-2 必须在外面等;
thread-1 执行完出来后,thread-2 才能进去。

所以它保护的是“临界区”。

临界区就是那段不能让多个线程同时执行的代码。

在这个例子里,临界区就是:

count++;

执行过程大概是什么样

假设 thread-1thread-2 都要调用:

increment();

加了 synchronized 以后,执行过程可以理解成这样:

thread-1 尝试进入 increment()
thread-1 拿到锁
thread-1 执行 count++
thread-1 执行完,释放锁

thread-2 之前没拿到锁,只能等待
thread-1 释放锁以后
thread-2 拿到锁
thread-2 执行 count++
thread-2 执行完,释放锁

这样虽然两个线程还是并发运行,但进入 count++ 这段代码时,会变成一个一个来。

所以结果就不会乱。


synchronized 代码块写法

除了写在方法上,也可以写成代码块。

比如:

package com.succos.thread;

public class SynchronizedBlockDemo {

    private static int count = 0;

    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        }, "thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("最终结果:" + count);
    }

    private static void increment() {
        synchronized (LOCK) {
            count++;
        }
    }
}

这里的重点是:

private static final Object LOCK = new Object();

和:

synchronized (LOCK) {
    count++;
}

这表示:谁要执行 count++,谁就必须先拿到 LOCK 这把锁。

拿不到就等。

拿到了才可以进去执行。


锁对象必须是同一个

这里有个非常重要的点:多个线程竞争的必须是同一把锁。

比如上面的写法是对的:

private static final Object LOCK = new Object();

synchronized (LOCK) {
    count++;
}

因为所有线程用的都是同一个 LOCK 对象。

但是如果写成这样,就不对了:

private static void increment() {
    Object lock = new Object();

    synchronized (lock) {
        count++;
    }
}

这段代码看起来也用了 synchronized,但其实没有达到效果。

因为每次调用 increment() 都会创建一个新的 lock 对象。

也就是说:

thread-1 用的是自己的锁;
thread-2 用的是另一个锁;
两个线程根本没有竞争同一把锁。

这样就锁不住共享资源。

所以我现在看 synchronized 时,会重点看一件事:

所有线程是不是在抢同一把锁?

如果不是同一把锁,那就没有意义。


synchronized 方法和代码块怎么选

synchronized 写在方法上比较简单:

private static synchronized void increment() {
    count++;
}

代码少,适合整个方法都需要保护的情况。

但如果一个方法里只有一小段代码需要保护,我更倾向于用代码块:

private static void increment() {

    // 这里可以做一些不需要加锁的操作

    synchronized (LOCK) {
        count++;
    }

    // 后面也可以继续做一些不需要加锁的操作
}

这样锁的范围更小。

锁的范围越大,等待的线程就越多,并发性能就越差。

所以一般原则是:

只锁真正需要保护的共享数据操作。

不要为了省事,把一大段无关逻辑都包进 synchronized


回到 PDF 项目里看 synchronized

在 PDF 水印项目里,如果每个线程只是处理自己的 PDF 文件,并且所有参数都是局部变量,那其实不一定需要 synchronized

比如这种写法一般没问题:

public void addWatermark(FileItemContext fileItemContext) {

    String sourcePath = fileItemContext.getSourcePath();
    String targetPath = fileItemContext.getTargetPath();

    // 当前线程处理自己的 PDF
}

因为 sourcePathtargetPath 都是局部变量,每个线程都有自己的那一份。

但如果我在多个线程里统计成功数量,比如:

private static int successCount = 0;

然后多个线程处理成功后都执行:

successCount++;

这就和 count++ 是一样的问题。

这时候就可以先用 synchronized 保护起来:

private static int successCount = 0;

private static final Object LOCK = new Object();

private static void addSuccessCount() {
    synchronized (LOCK) {
        successCount++;
    }
}

然后线程里不要直接写:

successCount++;

而是调用:

addSuccessCount();

这样统计结果就更稳定。

当然,真实项目里统计数量也可以用 AtomicInteger,公司的爬虫平台j后面可以再单独整理。这里先用 synchronized 理解锁的基本思想。


synchronized 不是越多越好

Synchronized 能解决线程安全问题,但不是哪里都加。

如果所有代码都加锁,就会让并发退化。

比如我有 10 个 PDF,本来可以 3 个线程同时处理。

但如果我把整个 PDF 处理过程都包进同一把锁里:

synchronized (LOCK) {
    service.addWaterMakerOfPDF(fileItemContext);
}

那实际效果可能变成:

thread-1 处理 a.pdf;
thread-2 等着;
thread-3 等着;

thread-1 处理完以后;
thread-2 才能处理 b.pdf;
thread-3 继续等。

这样多线程基本就失去意义了。

所以要分清楚:

PDF 文件本身独立处理,不需要用同一把锁串起来;
共享计数器、共享集合、共享状态,才需要考虑加锁。

这也是并发里比较重要的判断。

不是遇到多线程就加锁,而是先看有没有共享修改。


synchronized 的两个作用

从效果上看,synchronized 至少有两个作用。

一个是互斥。

也就是同一时间只能有一个线程进入被锁住的代码。

另一个是可见性。

一个线程在锁里面修改了共享变量,释放锁以后,其他线程再获得同一把锁时,可以看到前面线程修改后的结果。

我现在学习阶段先重点理解互斥就够了。

因为 count++ 这个例子,最直观的问题就是多个线程同时进来执行,导致更新丢失。


这一节小结

这一节我主要记住几个点:

1. synchronized 可以让同一时间只有一个线程进入某段代码;
2. 它保护的是临界区代码,不是变量本身;
3. 多个线程必须竞争同一把锁,synchronized 才有效;
4. 方法可以加 synchronized,代码块也可以加 synchronized;
5. 锁的范围不要太大,只锁真正需要保护的共享数据操作;
6. PDF 独立文件处理通常不需要锁,共享统计数据才需要考虑锁。

用一句话总结:

synchronized 的核心作用,就是把一段不能并发执行的代码变成“一个一个来”。

下一节继续看对象锁和类锁。

因为 synchronized 能写在普通方法上,也能写在静态方法上,还能锁 thisClass 对象。这个如果不理解清楚,后面在 Spring Boot 单例 Bean 里很容易混。