一、线程和进程的关系
在操作系统中,进程和线程是两个非常重要的概念。
简单来说:进程是程序运行起来后的一个独立空间,线程是进程里面真正执行代码的执行路线。
可以用公司来类比:
- 进程:可以理解为一家公司。
- 线程:可以理解为公司里的员工。
- 内存空间:可以理解为公司的办公区域和公共资源。
一家公司里面可以有多个员工一起工作。同样,一个进程里面也可以有多个线程同时执行任务。
比如启动一个 Java 程序:
java -jar app.jar
操作系统会创建一个 Java 进程,而这个进程里面可能会有多个线程,例如主线程、业务处理线程、垃圾回收线程等。
Java 进程
├── main 主线程
├── 业务线程 1
├── 业务线程 2
├── GC 垃圾回收线程
└── 其他 JVM 线程
再比如一个 Java Web 项目中,多个用户同时访问接口,Tomcat 通常会使用多个线程来处理这些请求。
用户 A 请求 → Tomcat 线程 1 处理
用户 B 请求 → Tomcat 线程 2 处理
用户 C 请求 → Tomcat 线程 3 处理
所以可以简单记住:进程负责提供运行空间,线程负责真正执行代码。
二、线程并发问题
多线程可以提高程序的并发处理能力,但是如果多个线程同时操作同一个共享变量,就可能出现线程安全问题。
下面这段代码中,两个线程都会对同一个静态变量 count 执行自增操作。
每个线程循环执行 100000 次,两个线程加起来理论上应该执行 200000 次,所以正常结果应该是 200000。
public class ThreadSafeTest {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + count);
}
}
但是实际运行时,输出结果可能不是 200000,而是小于 200000,例如:
最终结果:163482
最终结果:175921
最终结果:189305
这就是典型的线程安全问题。
为什么 count++ 会有线程安全问题?
虽然代码里只写了一句:
count++;
但是它底层并不是一步完成的,可以简单理解成三步:
1. 读取 count 的当前值
2. 对 count 加 1
3. 把新值写回 count
假设当前 count = 0,两个线程同时执行 count++,可能会出现下面这种情况:
线程 1 读取 count = 0
线程 2 读取 count = 0
线程 1 计算 0 + 1 = 1
线程 2 计算 0 + 1 = 1
线程 1 写回 count = 1
线程 2 写回 count = 1
两个线程明明都执行了一次自增,理论上结果应该是 2,但是最后结果却是 1。
这种情况就叫做丢失更新。
三、使用 synchronized 解决线程安全问题
要解决这个问题,就需要保证同一时间只能有一个线程执行 count++。
Java 中可以使用 synchronized 加锁。
public class ThreadSafeTest {
private static int count = 0;
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (LOCK) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (LOCK) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + count);
}
}
加锁之后,两个线程会争抢同一把锁。
线程 1 拿到锁 → 执行 count++ → 释放锁
线程 2 等待锁
线程 2 拿到锁 → 执行 count++ → 释放锁
线程 1 等待锁
这样就能保证同一时间只有一个线程修改 count,最终结果就会稳定输出:
最终结果:200000
注意:锁必须加在真正操作共享变量的位置。如果只是主线程加锁,而子线程没有进入锁,那么这个锁是没有意义的。
四、使用 ReentrantLock 解决线程安全问题
除了 synchronized,Java 还可以使用 ReentrantLock 来加锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadSafeTest {
private static int count = 0;
private static final Lock LOCK = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
LOCK.lock();
try {
count++;
} finally {
LOCK.unlock();
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
LOCK.lock();
try {
count++;
} finally {
LOCK.unlock();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + count);
}
}
使用 ReentrantLock 时,一定要在 finally 中释放锁。
LOCK.lock();
try {
// 需要加锁保护的代码
} finally {
LOCK.unlock();
}
这样即使中间代码出现异常,也能保证锁被正常释放,避免其他线程一直等待。
五、什么是分段锁?
上面的 synchronized 和 ReentrantLock 都属于全局锁,也就是所有线程都抢同一把锁。
如果所有线程操作的都是同一个资源,这样没有问题。但是如果线程操作的是不同资源,全部使用同一把锁就会降低并发效率。
比如系统里有三种序列号:
订单号 ORDER_NO
合同号 CONTRACT_NO
付款单号 PAY_NO
如果使用全局锁,那么生成订单号时,合同号和付款单号也都要等待。
线程 A 生成 ORDER_NO,占用全局锁
线程 B 生成 CONTRACT_NO,等待
线程 C 生成 PAY_NO,等待
但实际上,订单号、合同号、付款单号是不同资源,它们之间不应该互相阻塞。
这时就可以使用分段锁。
分段锁的思想是:同一个资源使用同一把锁,不同资源使用不同的锁。
ORDER_NO → 订单号锁
CONTRACT_NO → 合同号锁
PAY_NO → 付款单号锁
这样一来:
- 两个线程同时生成订单号,需要排队。
- 一个线程生成订单号,另一个线程生成合同号,可以并发执行。
六、分段锁代码示例
下面使用 ConcurrentHashMap 保存不同 key 对应的锁。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SegmentLockTest {
private static final Map<String, Lock> segmentLocks = new ConcurrentHashMap<>();
public static void main(String[] args) {
generateSequence("ORDER_NO");
generateSequence("CONTRACT_NO");
}
public static void generateSequence(String serialType) {
Lock lock = segmentLocks.computeIfAbsent(serialType, key -> new ReentrantLock());
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在生成:" + serialType);
// 模拟生成序列号逻辑
// 例如查询数据库当前流水号、更新流水号、返回新序列号
} finally {
lock.unlock();
}
}
}
这段代码中,serialType 就是分段锁的 key。
如果 serialType 是 ORDER_NO,就会使用订单号对应的锁;如果是 CONTRACT_NO,就会使用合同号对应的锁。
分段锁不是让同一个资源并发执行,而是让不同资源可以并发执行。
七、分段锁和全局锁的区别
| 锁类型 | 特点 | 适用场景 |
|---|---|---|
| 全局锁 | 所有线程都抢同一把锁 | 所有线程操作同一个资源 |
| 分段锁 | 不同资源使用不同锁 | 多个不同资源可以并发处理 |
举个例子:
全局锁:
ORDER_NO、CONTRACT_NO、PAY_NO 都抢同一把锁
分段锁:
ORDER_NO 抢订单号锁
CONTRACT_NO 抢合同号锁
PAY_NO 抢付款号锁
所以分段锁的优势不是绝对更快,而是减少不同资源之间不必要的等待。
八、分段锁使用时需要注意的问题
1. 同一个资源必须使用同一把锁
如果多个线程操作的是同一个变量,却使用不同的锁,那么仍然会有线程安全问题。
例如下面这种写法就是错误的:
String key = "KEY_" + index;
Lock lock = segmentLocks.computeIfAbsent(key, k -> new ReentrantLock());
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
因为不同线程拿的是不同锁,但是它们修改的却是同一个 count。
线程 1 拿 KEY_1 的锁,修改 count
线程 2 拿 KEY_2 的锁,修改 count
线程 3 拿 KEY_3 的锁,修改 count
这些线程并没有互相阻塞,所以 count++ 仍然是不安全的。
正确原则是:
同一个资源 → 同一把锁
不同资源 → 不同锁
2. segmentLocks 应该使用 ConcurrentHashMap
因为多个线程可能同时获取或创建锁,所以保存锁的 Map 应该使用 ConcurrentHashMap。
private static final Map<String, Lock> segmentLocks = new ConcurrentHashMap<>();
不建议使用普通的 HashMap,因为 HashMap 本身不是线程安全的。
3. Java 本地锁只在当前 JVM 内有效
无论是 synchronized、ReentrantLock,还是分段锁,本质上都是 Java 本地锁。
如果系统只部署了一个 Java 服务实例,这种锁可以控制当前 JVM 内部的多线程并发。
但是如果系统是多实例部署,例如:
服务器 A:Java 服务实例 1
服务器 B:Java 服务实例 2
那么每个 JVM 里都有自己的锁,它们之间互相不知道对方的存在。
这种情况下,只靠 Java 本地锁是不够的,还需要数据库锁、Redis 分布式锁,或者唯一索引兜底。
九、序列号生成场景中的线程安全
在业务系统中,生成订单号、合同号、付款号等序列号时,最怕出现重复号。
如果两个线程同时读取到同一个当前流水号,就可能生成重复序列号。
当前流水号:100
线程 A 读取到 100,生成 101
线程 B 读取到 100,生成 101
结果:两个线程都生成了 101
所以生成序列号时,一般需要保证同一种序列号在同一时刻只能有一个线程生成。
这时分段锁就比较适合:
Lock segmentLock = segmentLocks.computeIfAbsent(serialIdEname, k -> new ReentrantLock());
segmentLock.lock();
try {
// 查询当前流水号
// 更新流水号
// 生成新的序列号
} finally {
segmentLock.unlock();
}
其中 serialIdEname 就是序列号类型,例如:
ORDER_NO
CONTRACT_NO
PAY_NO
这样可以实现:
- 多个线程同时生成
ORDER_NO,需要排队。 - 一个线程生成
ORDER_NO,另一个线程生成CONTRACT_NO,可以并发执行。
十、为什么序列号生成建议使用独立事务?
序列号生成通常建议使用独立事务,因为序列号的核心要求是:可以跳号,但不能重复。
如果序列号更新和外层业务使用同一个事务,当外层业务回滚时,序列号也可能一起回滚。
例如:
1. 当前流水号是 100
2. 生成订单号 101
3. 更新流水号为 101
4. 后续保存订单失败
5. 外层事务回滚
6. 流水号又回到 100
下一次再生成订单号时,就可能再次生成 101。
如果使用独立事务,生成序列号后立即提交,即使后续业务失败,序列号也不会回退。
1. 当前流水号是 100
2. 生成订单号 101
3. 序列号事务单独提交
4. 后续保存订单失败
5. 订单回滚,但流水号不回滚
6. 下一次从 102 开始
这样可能会出现跳号,但是不会重复。
在大多数业务系统中,序列号跳号通常可以接受,但序列号重复一般不能接受。
十一、总结
本文主要整理了线程、进程、线程安全以及分段锁的基本概念。
- 进程:程序运行起来后的独立空间。
- 线程:进程里面真正执行代码的执行路线。
- 线程安全问题:多个线程同时操作同一个共享变量,可能导致结果错误。
- count++:不是原子操作,可能出现丢失更新。
- synchronized:Java 内置锁,可以保证同一时间只有一个线程进入代码块。
- ReentrantLock:显式锁,需要手动加锁和释放锁。
- 分段锁:同一个资源使用同一把锁,不同资源使用不同锁。
- 序列号生成:适合使用分段锁,保证同一种序列号串行生成,不同种序列号可以并发生成。
- 独立事务:可以避免业务回滚导致序列号回退,保证序列号不重复。
一句话总结:
线程安全的核心是:多个线程操作同一个共享资源时,必须做好并发控制。
分段锁的核心是:同一个资源排队,不同资源并发。

Comments NOTHING