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-1 和 thread-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
}
因为 sourcePath、targetPath 都是局部变量,每个线程都有自己的那一份。
但如果我在多个线程里统计成功数量,比如:
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 能写在普通方法上,也能写在静态方法上,还能锁 this 或 Class 对象。这个如果不理解清楚,后面在 Spring Boot 单例 Bean 里很容易混。