Appearance
线程同步
前置知识
在阅读本章前,你需要了解:
- Java 基础语法和面向对象编程
- 基础多线程概念:什么是线程,线程的创建和启动
为什么需要线程同步?
想象一下,你和几个朋友共同写一篇文档。如果你们同时在同一个段落编辑,结果很可能是一团糟。这种冲突在程序里就叫做“线程安全”问题。多个线程同时访问同一个资源,如果没有合适的保护措施,数据就会乱套。
这正是线程同步要解决的问题。它保证了同一时间只有一个线程能访问关键代码部分或共享数据,避免数据被同时修改导致不可预测的错误。
在本章,我们将一步步探索 Java 中非常核心的线程同步机制——synchronized 关键字,理解它背后的对象锁和类锁,还有大家都害怕的死锁问题。别担心,我会带着你一起一步步拆解这些看似抽象的概念。
掌握 synchronized:最简单的线程同步方式
我们先从一个简单得让人安心的例子开始,理解
synchronized是怎么帮线程“排队”的。
什么是 synchronized?
用简单的话说,synchronized 是 Java 提供的一种“排他锁”。它可以让某段代码在同一时刻只被一个线程执行,就像公共洗手间排队一样——进去了别人得等着。
你可以用它来修饰方法或者代码块,告诉 JVM 给这个操作上锁,别人如果想访问,只能排队等你执行完。
为什么需要它?
想象两个线程同时给一个账户存钱,如果没有同步,余额可能会错算。synchronized 就是守门员,防止坏事发生。
基础用法示例1:对象锁(实例锁)
java
public class Counter {
private int count = 0;
// 增加计数的方法,使用 synchronized 保证线程安全
public synchronized void increment() {
count++; // 关键操作
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 创建两个线程,分别调用 increment 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数值:" + counter.getCount());
}
}这段代码做了什么?
- 两个线程同时调用
increment()方法,每个循环给count加 1。 - 因为
increment是synchronized,每次只有一个线程能进入该方法,保证count自增操作不会被多个线程打断。 - 最后打印出来的结果一定是 2000(1000 + 1000),不会出现小于 2000 的“丢失更新”问题。
代码解析:
- synchronized 方法:相当于为当前对象实例
counter加了一把锁,这把锁是“对象锁”。 - 线程竞争时:如果一个线程持有锁,其他线程必须等待,直到锁释放。
- 代码块内执行顺序:每次进来都会先拿锁,没有锁拿不到,代码不会并发执行。
对象锁和类锁的区别
那么,一把“锁”到底锁的是什么?在 Java 里,synchronized 可以锁对象,也可以锁类。这两者的区别挺重要,搞懂它们代码才能写得既安全又高效。
对象锁(实例锁)
像我们刚才的例子,给实例方法加 synchronized,锁的是对象本身。一个对象有它自己的锁,多个线程访问同一个对象时才能体现锁的效果。
类锁(static synchronized)
如果是给静态方法加 synchronized,锁的就是这个类的 Class 对象。无论多少实例,这把类锁是唯一的。
示例2:类锁
java
public class ClassLevelLockDemo {
private static int staticCount = 0;
// 静态方法加锁,锁的是 Class 对象
public static synchronized void incrementStatic() {
staticCount++;
}
public static int getStaticCount() {
return staticCount;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
incrementStatic();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
incrementStatic();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终静态计数值:" + getStaticCount());
}
}这段代码做了什么?
- 这里我们用静态方法给
staticCount自增,且加了类锁。 - 多个线程同时执行时,同一时刻只有一个线程能进入
incrementStatic(),防止数据冲突。 - 和对象锁不同,类锁是针对整个类的,不管多少实例,共享一把锁。
何时用类锁?
- 类锁适用于所有实例公用的数据(如静态变量)。
- 对象锁更适合保护实例变量。
逐渐深入:代码块同步的细粒度控制
刚才的示例中,我们锁的是整个方法,有时开销比较大,特别是方法中只有部分代码需要同步时。幸好,synchronized 还可以修饰代码块,控制锁的范围更细,提升性能。
示例3:synchronized 代码块
java
public class FineGrainedLock {
private int count = 0;
private final Object lock = new Object(); // 自定义锁对象
public void increment() {
// 只给关键代码加锁,避免锁住整个方法
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
FineGrainedLock counter = new FineGrainedLock();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数值:" + counter.getCount());
}
}这段代码做了什么?
- 用一个私有的
lock对象作为锁,而非整个实例。 - 只给
count++这句关键代码加锁,缩小锁的颗粒度。 - 这样,其他不需要同步的代码不会被阻塞,提高性能。
为什么自定义锁对象?
- 加锁时指定了锁对象,能灵活控制同步范围。
- 避免把整个实例锁住,减少线程之间的等待。
- 这是实际项目里常见的优化手段。
死锁:线程同步的“陷阱”之一
掌握了同步,接下来我们必须面对一个棘手问题——死锁。
死锁发生时,两个或以上的线程互相等待对方释放锁,结果都卡在那,程序无法继续。这就像两人面对一条狭窄的桥,互不退让,永远堵在那里。
死锁示例
java
public class DeadlockDemo {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
System.out.println("线程1获得lockA");
try {
// 模拟操作耗时
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println("线程1获得lockB");
}
}
}
public void method2() {
synchronized (lockB) {
System.out.println("线程2获得lockB");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockA) {
System.out.println("线程2获得lockA");
}
}
}
public static void main(String[] args) {
DeadlockDemo demo = new DeadlockDemo();
Thread t1 = new Thread(demo::method1);
Thread t2 = new Thread(demo::method2);
t1.start();
t2.start();
}
}这段代码做了什么?
- 线程1先抢
lockA,再尝试拿lockB。 - 线程2先抢
lockB,再尝试拿lockA。 - 两个线程相互等待对方释放锁,造成死锁,程序挂在那里了。
死锁分析:
- 互相持有对方需要的资源且不释放。
- 所有线程都在等待,不会继续执行。
- 程序“瘫痪”,无法完成任务。
实战建议
💡 实战建议
- 尽量避免嵌套锁:特别是不同顺序获取多个锁,容易产生死锁。
- 保证锁顺序一致:如果必须同时拿多个锁,所有线程以统一顺序获取,降低死锁概率。
- 锁的粒度要合适:过大锁范围会影响性能,过小则无法保证安全。
- 使用 Java 并发包中的工具:如
ReentrantLock,支持尝试锁定,可以设置超时,减少死锁概率。 - 及时释放锁:尤其是不在同步块中使用阻塞操作,避免持有锁时间过长。
小结
synchronized是 Java 最直接的线程同步手段,保证了多个线程对共享资源的互斥访问。- 对象锁是实例级的,类锁是基于
Class对象,锁的范围和作用对象不同。 - 通过代码块同步,可以控制锁的细粒度,获得更好的性能。
- 死锁是同步编程必需避免的坑,通过合理设计锁顺序和锁粒度可以降低风险。
延伸思考
- 如果你设计一个高并发系统,如何权衡锁的粒度和系统性能?
synchronized和ReentrantLock在实现和使用上有哪些差异?你更倾向用哪个?为什么?- 有没有可能在不使用
synchronized的情况下保证线程安全?
期待你动手试试上面的代码,感受同步带来的保障和挑战。线程同步不仅是技术问题,还是一门艺术,掌握它,能让你的多线程编程稳健且高效。祝你编码顺利!
