JUC并发编程:共享模型之管程与悲观锁(synchronized)详解
摘要:本文围绕 Java 并发编程管程与悲观锁展开。先阐述共享模型下多线程操作共享变量的竞态条件及临界区概念;再讲 synchronized 使用、锁粒度优化,分析变量线程安全;接着介绍 wait/notify、Park/Unpark 及线程状态转换;最后探讨多锁优化并发与死锁等活跃性问题。
1. 共享模型之管程
1.1 共享带来的问题
1.1.1 Java 的体现
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
static int counter = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {counter++;}}, "t1");Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {counter--;}}, "t2");t1.start();t2.start();t1.join();t2.join();log.debug("{}",counter);
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
单线程执行情况
在单线程环境下,8 行代码会按顺序执行(无交错),结果准确:
- 读取静态变量 i 的值(0)
- 准备常数 1
- 执行加法运算(线程内 i=1)
- 将结果 1 写入静态变量 i
- 再次读取静态变量 i 的值(1)
- 准备常数 1
- 执行减法运算(线程内 i=0)
- 将结果 0 写入静态变量 i
最终静态变量 i 的结果为 0,运算正确。
多线程执行情况(可能出现交错)
多线程环境下,由于线程切换,8 行代码可能交错执行,导致结果异常:
情况 1:结果为负数
- 线程 2 读取静态变量 i 的值(0)
- 线程 2 准备常数 1
- 线程 2 执行减法运算(线程内 i=-1)
- 发生上下文切换
- 线程 1 读取静态变量 i 的值(0)
- 线程 1 准备常数 1
- 线程 1 执行加法运算(线程内 i=1)
- 线程 1 将结果 1 写入静态变量 i
- 发生上下文切换
- 线程 2 将结果 - 1 写入静态变量 i
最终静态变量 i 的结果为 - 1。
情况 2:结果为正数
- 线程 1 读取静态变量 i 的值(0)
- 线程 1 准备常数 1
- 线程 1 执行加法运算(线程内 i=1)
- 发生上下文切换
- 线程 2 读取静态变量 i 的值(0)
- 线程 2 准备常数 1
- 线程 2 执行减法运算(线程内 i=-1)
- 线程 2 将结果 - 1 写入静态变量 i
- 发生上下文切换
- 线程 1 将结果 1 写入静态变量 i
最终静态变量 i 的结果为 1。
1.1.2 临界区
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
每个线程有自己的栈空间,但堆内存、静态变量、全局变量等是共享的。
如果多个线程只是读取共享资源,不会引发问题,因为读取操作不会改变资源状态。
关键点:
临界区是代码段,不是数据。
进入临界区的线程可能会修改共享资源。
若多个线程同时进入临界区,并且执行顺序不确定(指令交错),就可能导致数据不一致。
static int counter = 0;static void increment()
// 临界区
{counter++;
}static void decrement()
// 临界区
{counter--;
}
为什么是临界区?
counter
是静态变量,属于共享资源。
increment()
和decrement()
中都对counter
进行了写操作。如果多个线程同时调用这两个函数,就构成了对临界区的并发访问。
1.1.3 竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。所以,竞态条件的本质是:程序的正确性依赖于线程执行的顺序,而这个顺序是不确定的。
1.2 synchronized 解决方案
应用之互斥
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
阻塞式的解决方案:synchronized,Lock
非阻塞式的解决方案:原子变量
本次课使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取该【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
1.2.1 synchronized 语法
synchronized(同一个对象) // 线程1, 线程2(blocked)
{临界区
}
使用synchronized解决问题
static int counter = 0;
static final Object room = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {synchronized (room) {counter++;}}}, "t1");Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {synchronized (room) {counter--;}}}, "t2");t1.start();t2.start();t1.join();t2.join();log.debug("{}",counter);
}
用图来表示:
思考
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
为了加深理解,请思考下面的问题:
1. 如果把 synchronized (obj) 放在 for 循环的外面,如何理解?-- 原子性
2. 如果 t1 synchronized (obj1) 而 t2 synchronized (obj2) 会怎样运作?-- 锁对象
3. 如果 t1 synchronized (obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
1.2.2 面向对象改进
把需要保护的共享变量放入一个类:
class Room {int value = 0;public void increment() {synchronized (this) {value++;}}public void decrement() {synchronized (this) {value--;}}public int get() {synchronized (this) {return value;}}
}@Slf4j
public class Test1 {public static void main(String[] args) throws InterruptedException {Room room = new Room();Thread t1 = new Thread(() -> {for (int j = 0; j < 5000; j++) {room.increment();}}, "t1");Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {room.decrement();
}
}, "t2");t1.start();
t2.start();t1.join();
t2.join();log.debug("count: {}" , room.get());
}
}
1.3 方法上的 synchronized
1.3.1 线程八锁
所谓的 "线程八锁",其实就是考察 synchronized 锁住的是哪个对象。
情况 1:12 或 21
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况 2:1s 后 12,或 2 1s 后 1
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况 3:3 1s 12 或 23 1s 1 或 32 1s 1
@Slf4j(topic = "c.Number")
class Number {public synchronized void a() {sleep(1);log.debug("1");}public synchronized void b() {log.debug("2");}public void c() {log.debug("3");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n1.b(); }).start();new Thread(() -> { n1.c(); }).start();}
}
情况 4:2 1s 后 1
@Slf4j(topic = "c.Number")
class Number {public synchronized void a() {sleep(1);log.debug("1");}public synchronized void b() {log.debug("2");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();Number n2 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n2.b(); }).start();}
}
锁的不是同一个对象(a 锁的n1,b 锁的n2)
情况 5:2 1s 后 1
@Slf4j(topic = "c.Number")
class Number {// 静态同步方法 - 锁的是 Number.class 类对象public static synchronized void a() {sleep(1); // 睡眠1秒log.debug("1");}// 实例同步方法 - 锁的是 this 对象(n1实例)public synchronized void b() {log.debug("2");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n1.b(); }).start();}
}
静态同步方法 - 锁的是 Number.class 类对象
实例同步方法 - 锁的是 this 对象(n1实例)
情况 6:1s 后 12,或 2 1s 后 1
@Slf4j(topic = "c.Number")
class Number {public static synchronized void a() {sleep(1);log.debug("1");}public static synchronized void b() {log.debug("2");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n1.b(); }).start();}
}
情况 7:2 1s 后 1
@Slf4j(topic = "c.Number")
class Number {public static synchronized void a() {sleep(1);log.debug("1");}public synchronized void b() {log.debug("2");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();Number n2 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n2.b(); }).start();}
}
情况 8:1s 后 12,或 2 1s 后 1
@Slf4j(topic = "c.Number")
class Number {public static synchronized void a() {sleep(1);log.debug("1");}public static synchronized void b() {log.debug("2");}
}public class Main {public static void main(String[] args) {Number n1 = new Number();Number n2 = new Number();new Thread(() -> { n1.a(); }).start();new Thread(() -> { n2.b(); }).start();}
}
1.4 变量的线程安全分析
成员变量和静态变量是否线程安全?
如果它们没有共享,则线程安全
如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
如果只有读操作,则线程安全
如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
局部变量是线程安全的
但局部变量引用的对象则未必
如果该对象没有逃离方法的作用访问,它是线程安全的
如果该对象逃离方法的作用范围,需要考虑线程安全
1.4.1 局部变量线程安全分析
public static void test1() {
int i = 10;i++;
}
每个线程调用 test1 () 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,故不存在共享
public static void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=0
0: bipush 10
2: istore_0
3: iinc 0, 1
6: return
LineNumberTable:
line 10: 0
line 11: 3
line 12: 6
LocalVariableTable:
Start Length Slot Name Signature
3 4 0 i I
如图:
局部变量的引用稍有不同
先看一个成员变量的例子
class ThreadUnsafe {ArrayList<String> list = new ArrayList<>();public void method1(int loopNumber) {for (int i = 0; i < loopNumber; i++) {// 临界区, 会产生竞态条件method2();method3();// 临界区结束}}private void method2() {list.add("1");}private void method3() {list.remove(0);}
}
执行结果其中一种情况是,如果线程 2 还未 add,线程 1 remove 就会报错
分析:
无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量。
method3 与 method2 分析相同。
将 list 修改为局部变量
class ThreadSafe {public final void method1(int loopNumber) {ArrayList<String> list = new ArrayList<>();for (int i = 0; i < loopNumber; i++) {method2(list);method3(list);}}private void method2(ArrayList<String> list) {list.add("1");}private void method3(ArrayList<String> list) {list.remove(0);}
}
无报错
分析:
list 是局部变量,每个线程调用时会创建其不同实例,没有共享。而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象。method3 的参数分析与 method2 相同。
1.4.2 方法访问修饰符带来的思考
如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?
情况 1:有其它线程调用 method2 和 method3
情况 2:在 情况 1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法
class ThreadSafe {public final void method1(int loopNumber) {ArrayList<String> list = new ArrayList<>();for (int i = 0; i < loopNumber; i++) {method2(list);method3(list);}}private void method2(ArrayList<String> list) {list.add("1");}private void method3(ArrayList<String> list) {list.remove(0);}}class ThreadSafeSubClass extends ThreadSafe{@Overridepublic void method3(ArrayList<String> list) {new Thread(() -> {list.remove(0);}).start();}}
可以看出 private 或 final 提供【线程安全】的意义所在,请体会开闭原则中的【闭】
1.4.3 常见线程安全类
在 Java 中,以下类常被提及具备线程安全特性:
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的各类(如 ConcurrentHashMap、LinkedBlockingQueue 等)
需要明确的是,这里所说的 “线程安全”,核心含义是指:当多个线程同时对这些类的同一个实例发起调用,执行其内部的某个公开方法时,该实例能够通过内部的同步机制(如 synchronized 关键字、CAS 操作等)保证数据操作的原子性、可见性和有序性,避免出现数据不一致、脏读、指令重排等线程安全问题,确保方法执行结果符合预期逻辑。
也可以理解为:
Hashtable table = new Hashtable();new Thread(()->{
table.put("key", "value1");
}).start();new Thread(()->{
table.put("key", "value2");
}).start();
它们的每个方法是原子的。
但注意它们多个方法的组合不是原子的,见后面分析。
1.4.4 线程安全类方法的组合
1.4.5 不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此其方法都是线程安全的。
String 有 replace,substring 等方法【可以】改变值啊,那么这些方法是如何保证线程安全的呢?
public class Immutable{private int value = 0;public Immutable(int value){this.value = value;}public int getValue(){return this.value;}}
如果想增加一个增加的方法呢?
public class Immutable{private final int value; // 使用final修饰,确保构造后无法修改public Immutable(int value){this.value = value;}public int getValue(){return this.value;}public Immutable add(int v){return new Immutable(this.value + v);}}
1.4.6 实例分析
例 1:
public class MyServlet extends HttpServlet {// 是否安全? 不安全,多个线程共享一个HashMap实例,可能导致并发问题Map<String,Object> map = new HashMap<>();// 是否安全? 不安全,String虽然不可变,但如果有代码修改S1的引用会有线程安全问题String S1 = "...";// 是否安全? 安全,final修饰的String不可变,且引用不能被修改final String S2 = "...";// 是否安全? 不安全,Date是可变对象,多个线程可能同时修改其状态Date D1 = new Date();// 是否安全? 不安全,虽然引用不可变,但Date对象本身是可变的,可能被多个线程修改final Date D2 = new Date();public void doGet(HttpServletRequest request, HttpServletResponse response) {// 使用上述变量}}
关于线程安全性的说明:
map
:不安全,HashMap
不是线程安全的,多线程环境下操作会有并发问题S1
:不安全,虽然String
对象不可变,但S1
引用可以被修改,多线程环境下可能不一致S2
:安全,final
保证引用不可变,且String
本身不可变D1
:不安全,Date
是可变对象,多线程可修改其内部状态D2
:不安全,final
只保证引用不变,但Date
对象本身可变,多线程仍可修改其内容
例 2:
public class MyServlet extends HttpServlet {// 是否安全?private UserService userService = new UserServiceImpl();public void doGet(HttpServletRequest request, HttpServletResponse response) {userService.update(...);}}public class UserServiceImpl implements UserService {// 记录调用次数private int count = 0;public void update() {// ...count++;}}
存在线程安全问题。
UserServiceImpl
中的count
变量是实例变量,会被多个线程共享,当多个线程同时调用update()
方法时,count++
操作不是原子操作,可能导致计数不准确。要解决这个问题,可以:
- 使用
synchronized
关键字修饰update()
方法- 使用
AtomicInteger
替代int
类型的count
例 3:
@Aspect
@Component
public class MyAspect {// 是否安全?private long start = 0L;@Before("execution(* *(..))")public void before() {start = System.nanoTime();}@After("execution(* *(..))")public void after() {long end = System.nanoTime();System.out.println("cost time:" + (end-start));}}
这个切面类存在线程安全问题。
start
是实例变量,会被多个线程共享。当多个线程同时执行被切面拦截的方法时,可能出现一个线程的before()
方法覆盖了另一个线程设置的start
值,导致计算的时间不准确。静态变量定被所有线程共享;实例变量是否被共享,取决于它所属对象是否被多个线程共享在 Servlet 场景中,正因为 Servlet 实例被所有请求线程共享,所以它的实例变量才会被多线程共享。
例 4:
public class MyServlet extends HttpServlet {// 是否安全private UserService userService = new UserServiceImpl();public void doGet(HttpServletRequest request, HttpServletResponse response) {userService.update(...);}}public class UserServiceImpl implements UserService {// 是否安全private UserDao userDao = new UserDaoImpl();public void update() {userDao.update();}}public class UserDaoImpl implements UserDao {public void update() {String sql = "update user set password = ? where username = ?";// 是否安全try (Connection conn = DriverManager.getConnection("","","")){// ...} catch (Exception e) {// ...}}}
1. userService
:本身的引用是安全的,但安全性取决于UserServiceImpl
的实现
2. userDao
:在UserServiceImpl
中作为实例变量,其安全性取决于UserDaoImpl
的实现
3. Connection
:在update()
方法内部创建的局部变量,每个线程调用时都会创建新的连接对象,是线程安全的
例 5:
public class MyServlet extends HttpServlet {// 是否安全private UserService userService = new UserServiceImpl();public void doGet(HttpServletRequest request, HttpServletResponse response) {userService.update(...);}}public class UserServiceImpl implements UserService {// 是否安全private UserDao userDao = new UserDaoImpl();public void update() {userDao.update();}}public class UserDaoImpl implements UserDao {// 是否安全private Connection conn = null;public void update() throws SQLException {String sql = "update user set password = ? where username = ?";conn = DriverManager.getConnection("","","");// ...conn.close();}}
UserDaoImpl
中的conn
变量存在严重的线程安全问题:
conn
是实例变量,会被多个线程共享- 当多个线程同时调用
update()
方法时,可能出现一个线程关闭了另一个线程正在使用的连接- 可能导致一个线程的
conn
被另一个线程覆盖,造成连接泄露或操作错误
例 6:
public class MyServlet extends HttpServlet {// 是否安全private UserService userService = new UserServiceImpl();public void doGet(HttpServletRequest request, HttpServletResponse response) {userService.update(...);}}public class UserServiceImpl implements UserService {public void update() {UserDao userDao = new UserDaoImpl();userDao.update();}}public class UserDaoImpl implements UserDao {// 是否安全private Connection conn = null;public void update() throws SQLException {String sql = "update user set password = ? where username = ?";conn = DriverManager.getConnection("","","");// ...conn.close();}}
1.5 wait notify
1.5.1 API 介绍及原理
obj.wait () 让进入 object 监视器的线程到 waitSet 等待
obj.notify () 在 object 上正在 waitSet 等待的线程中挑一个唤醒
obj.notifyAll () 让 object 上正在 waitSet 等待的线程全部唤醒
这三个方法是线程间进行协作通信的重要手段,均属于 Object 类的原生方法。需要注意的是,调用这些方法的前提是当前线程必须已经获取到该对象的监视器锁(即处于 synchronized 同步块或同步方法中),否则会抛出 IllegalMonitorStateException 异常。
1.5.2 wait notify 的正确姿势
开始之前先看看 sleep (long n) 和 wait (long n) 的区别:
sleep 是 Thread 方法,而 wait 是 Object 的方法
sleep 不需要强制和 synchronized 配合使用,但 wait 需要 和 synchronized 一起用
sleep 在睡眠的同时,不会释放对象锁,但 wait 在等待的时候会释放对象锁
它们执行后的状态均为 TIMED_WAITING
方法一:
static final Object room = new Object();static boolean hasCigarette = false;static boolean hasTakeout = false;
思考下面的解决方案好不好,为什么?
new Thread(() -> {synchronized (room) {log.debug("有烟没?[{}]", hasCigarette);if (!hasCigarette) {log.debug("没烟,先歇会!");sleep(2);}log.debug("有烟没?[{}]", hasCigarette);if (hasCigarette) {log.debug("可以开始干活了");}}
}, "小南").start();for (int i = 0; i < 5; i++) {new Thread(() -> {synchronized (room) {log.debug("可以开始干活了");}}, "其它人").start();
}sleep(1);new Thread(() -> {// 这里需要加 synchronized (room)synchronized (room) {hasCigarette = true;log.debug("烟到了噢!");}
}, "送烟的").start();
1. 其他干活线程:阻塞效率低(门被小南反锁)
小南线程进入
synchronized (room)
后,会持有 room 锁 2 秒(通过Thread.sleep(2000)
实现)。这期间 5 个 "其他人" 线程会一直阻塞在room
锁的等待队列中 —— 相当于小南在房间里反锁了门睡觉,其他人想进门干活只能在门外排队,完全无法并行,效率极低。2. 小南线程:烟提前到也无法唤醒(死等 2 秒,不 "听通知")
小南判断 "没烟" 后,用
Thread.sleep(2000)
强制等待 2 秒,而非room.wait()
:
sleep()
不会释放锁,即使 1 秒后送烟线程把烟送到,小南也不会立刻醒来,必须硬等够 2 秒才能再次判断 "是否有烟";- 反观
wait()
,会主动释放锁并进入等待状态,一旦收到notify()
通知(烟送到)就能立刻唤醒,无需无效等待。
解决方法,使用 wait - notify 机制
方法二:
// 新增共享条件:是否有外卖(模拟多个等待条件)
static boolean hasTakeout = false;
static final Object room = new Object();
static boolean hasCigarette = false;public static void main(String[] args) throws InterruptedException {// 小南:等烟new Thread(() -> {synchronized (room) {log.debug("有烟没?[{}]", hasCigarette);if (!hasCigarette) {log.debug("没烟,先歇会!");try {room.wait(2000); // 等待烟的条件} catch (InterruptedException e) {e.printStackTrace();}}// 被唤醒后重新判断条件(避免虚假唤醒)if (hasCigarette) {log.debug("有烟了,可以开始干活了");} else {log.debug("等了2秒还没烟,放弃干活");}}}, "小南").start();// 小北:等外卖(新增的等待其他条件的线程)new Thread(() -> {synchronized (room) {log.debug("有外卖没?[{}]", hasTakeout);if (!hasTakeout) {log.debug("没外卖,先歇会!");try {room.wait(2000); // 等待外卖的条件} catch (InterruptedException e) {e.printStackTrace();}}// 被唤醒后重新判断条件if (hasTakeout) {log.debug("有外卖了,可以开始吃饭了");} else {log.debug("等了2秒还没外卖,放弃吃饭");}}}, "小北").start();// 其他干活线程for (int i = 0; i < 3; i++) {new Thread(() -> {synchronized (room) {log.debug("可以开始干活了");}}, "其它人-" + i).start();}// 1秒后先送烟Thread.sleep(1000);new Thread(() -> {synchronized (room) {hasCigarette = true;log.debug("烟到了噢!");room.notify(); // 随机唤醒一个等待线程(可能是小南,也可能是小北)}}, "送烟的").start();// 再等1秒送外卖Thread.sleep(1000);new Thread(() -> {synchronized (room) {hasTakeout = true;log.debug("外卖到了噢!");room.notify(); // 唤醒剩下的等待线程}}, "送外卖的").start();
}
当有多个线程等待不同条件(如小南等烟、小北等外卖),原代码用
room.notify()
会有问题:
notify()
会随机唤醒一个在room
上等待的线程,无法精准唤醒对应条件的线程;- 例如:送烟线程调用
notify()
,可能误唤醒等待外卖的小北 —— 小北被唤醒后检查条件(hasTakeout
仍为false
),发现不是自己等的条件,最终只能放弃,导致 "唤醒无效";- 同时,真正需要唤醒的小南可能仍在等待,直到
wait(2000)
超时才醒来,造成逻辑混乱。
方法三:多个线程等待不同条件解决方案
// 共享变量
static boolean hasTakeout = false;
static final Object room = new Object();
static boolean hasCigarette = false;public static void main(String[] args) throws InterruptedException {// 小南:等烟(条件1)new Thread(() -> {synchronized (room) {log.debug("有烟没?[{}]", hasCigarette);if (!hasCigarette) {log.debug("没烟,先歇会!");try {room.wait(); // 无超时等待(永久等,直到被唤醒)} catch (InterruptedException e) {e.printStackTrace();}}log.debug("有烟没?[{}]", hasCigarette);if (hasCigarette) {log.debug("可以开始干活了");} else {log.debug("没干成活...");}}}, "小南").start();// 小女:等外卖(条件2)new Thread(() -> {synchronized (room) {log.debug("外卖送到没?[{}]", hasTakeout);if (!hasTakeout) {log.debug("没外卖,先歇会!");try {room.wait(); // 无超时等待} catch (InterruptedException e) {e.printStackTrace();}}log.debug("外卖送到没?[{}]", hasTakeout);if (hasTakeout) {log.debug("可以开始干活了");} else {log.debug("没干成活...");}}}, "小女").start();// 1秒后只送外卖(仅1个唤醒源)Thread.sleep(1);new Thread(() -> {synchronized (room) {hasTakeout = true;log.debug("外卖到了噢!");room.notify(); // 随机唤醒1个等待线程}}, "送外卖的").start();
}
notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,称之为【虚假唤醒】。
方法四:notifyAll
new Thread(() -> {synchronized (room) {hasTakeout = true;log.debug("外卖到了噢!");room.notifyAll();}
}, "送外卖的").start();
notifyAll()
虽然能唤醒所有等待线程,避免 "漏唤醒",但如果用if + wait()
判断条件,线程被唤醒后仅会检查一次条件—— 若此时条件仍不成立(比如被 “虚假唤醒”),线程就会直接退出等待逻辑,失去重新等待的机会,最终导致业务逻辑异常。
方法四:while + wait
synchronized (room) {while (!hasCigarette) { // 初始条件不成立,进入循环log.debug("没烟,先歇会!");room.wait(); // 被外卖唤醒后,回到while头部重新判断// 重新判断:hasCigarette仍为false,继续循环}// 只有当hasCigarette为true时,才会走到这里log.debug("有烟没?[{}]", hasCigarette); // 输出:有烟没?[true]log.debug("可以开始干活了"); // 正确执行
}
1.7 Park & Unpark
1.7.1 基本使用
它们是 LockSupport 类中的方法:
// 暂停当前线程
LockSupport.park();// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
先 park 再 unpark
Thread t1 = new Thread(() -> {log.debug("start...");sleep(1);log.debug("park...");LockSupport.park();log.debug("resume...");
}, "t1");t1.start();sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);
先 unpark 再 park
Thread t1 = new Thread(() -> {log.debug("start...");sleep(2);log.debug("park...");LockSupport.park();log.debug("resume...");
}, "t1");t1.start();sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);
1.7.2 特点
LockSupport.park/unpark
与 Object.wait/notify
作为 Java 线程阻塞 / 唤醒的两种核心机制,核心差异可总结为以下三点:
锁依赖不同
wait/notify/notifyAll
必须在synchronized
同步块中使用,强依赖于对象的 Monitor 锁;park/unpark
无需依赖任何锁依赖,可直接通过线程实例操作阻塞 / 唤醒,无需关联同步锁。
唤醒精度不同
notify
随机唤醒一个在锁上等待的线程,notifyAll
唤醒所有等待线程,均无法精准指定目标线程,存在 “虚假唤醒” 或 “无效唤醒” 问题;park/unpark
以线程为单位精准控制,unpark(Thread t)
可直接唤醒指定线程,无随机唤醒风险。
超前操作有效性不同
wait/notify
中,若notify
先于wait
执行(无线程等待),唤醒信号 "失效",后续wait
仍阻塞;park/unpark
支持 "超前唤醒",先调用unpark
颁发 "许可",后续park
直接消耗许可,不阻塞。park/unpark
:先喝解药再喝毒药,就不会中毒
简言之,wait/notify
适合基于锁的条件等待场景,而 park/unpark
更适合线程级的精准控制,是并发工具的底层实现基础。
1.8 重新理解线程状态转换
情况 1 NEW → RUNNABLE
当线程对象调用 t.start()
方法时,线程状态由 NEW → RUNNABLE。此时线程进入可运行状态,等待操作系统调度获取 CPU 时间片。
情况 2 RUNNABLE ←→ WAITING
t 线程用 synchronized (obj) 获取了对象锁后
- 调用 obj.wait () 方法时,t 线程从 RUNNABLE --> WAITING
- 调用 obj.notify (), obj.notifyAll (), t.interrupt () 时
- 竞争锁成功,t 线程从 WAITING --> RUNNABLE
- 竞争锁失败,t 线程从 WAITING --> BLOCKED
public class TestWaitNotify {final static Object obj = new Object();private static final Logger log = LoggerFactory.getLogger(TestWaitNotify.class);public static void main(String[] args) throws InterruptedException {// 线程t1:获取锁后等待new Thread(() -> {synchronized (obj) {log.debug("执行....");try {obj.wait(); // 释放锁,进入WAITING状态} catch (InterruptedException e) {e.printStackTrace();}log.debug("其它代码...."); // 被唤醒并获取锁后执行}}, "t1").start();// 线程t2:获取锁后等待new Thread(() -> {synchronized (obj) {log.debug("执行....");try {obj.wait(); // 释放锁,进入WAITING状态} catch (InterruptedException e) {e.printStackTrace();}log.debug("其它代码...."); // 被唤醒并获取锁后执行}}, "t2").start();// 主线程休眠0.5秒,确保t1、t2进入等待状态Thread.sleep(500);log.debug("唤醒 obj 上其它线程");// 主线程获取锁后唤醒所有等待线程synchronized (obj) {obj.notifyAll(); // 唤醒obj对象等待队列中所有线程}}
}
情况 3 RUNNABLE ←→ WAITING
- 当前线程调用 t.join () 方法时,当前线程从 RUNNABLE→ WAITING
- 注意是当前线程在 t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的 interrupt () 时,当前线程从 WAITING → RUNNABLE
情况 4 RUNNABLE ←→ WAITING
- 当前线程调用 LockSupport.park () 方法会让当前线程从 RUNNABLE --> WAITING
- 调用 LockSupport.unpark () 或调用了线程的 interrupt (),会让目标线程从 WAITING → RUNNABLE
情况 5 RUNNABLE ←→ TIMED_WAITING
t 线程用 synchronized (obj) 获取了对象锁后
- 调用 obj.wait (long n) 方法时,t 线程从 RUNNABLE → TIMED_WAITING
- t 线程等待时间超过了 n 毫秒,或调用 obj.notify (),obj.notifyAll (),t.interrupt () 时
- 竞争锁成功,t 线程从 TIMED_WAITING → RUNNABLE
- 竞争锁失败,t 线程从 TIMED_WAITING → BLOCKED
情况 6 RUNNABLE ←→ TIMED_WAITING
- 当前线程调用 t.join (long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
- 注意是当前线程在 t 线程对象的监视器上等待
- 当前线程等待时间超过了 n 毫秒,或 t 线程运行结束,或调用了当前线程的 interrupt () 时,当前线程从 TIMED_WAITING --> RUNNABLE
情况 7 RUNNABLE ←→ TIMED_WAITING
- 当前线程调用 Thread.sleep (long n),当前线程从 RUNNABLE → TIMED_WAITING
- 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING → RUNNABLE
情况 8 RUNNABLE ←→ TIMED_WAITING
- 当前线程调用 LockSupport.parkNanos (long nanos) 或 LockSupport.parkUntil (long millis) 时,当前线程从 RUNNABLE → TIMED_WAITING
- 调用 LockSupport.unpark (目标线程) 或调用了线程的 interrupt (), 或是等待超时,会让目标线程从 TIMED_WAITING → RUNNABLE
情况 9 RUNNABLE ←→ BLOCKED
- t 线程用 synchronized (obj) 获取了对象锁时如果竞争失败,从 RUNNABLE → BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED → RUNNABLE,其它失败的线程仍然 BLOCKED
情况 10 RUNNABLE → TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
1.9 多把锁
1.9.1 多把不相干的锁
一间大屋子包含两个互不相干的功能:睡觉和学习。
若小南想学习,小女想睡觉时,若仅用一个屋子(对应一个对象锁),会导致两人无法同时进行活动,并发效率很低。
优化方案是:为两个功能分别准备独立的房间(对应多个对象锁)。这样一来,学习和睡觉可以并行进行,互不干扰,有效提升了并发度。
例如:
class BigRoom {public void sleep() {synchronized (this) {log.debug("sleeping 2 小时");Sleeper.sleep(2);}}public void study() {synchronized (this) {log.debug("study 1 小时");Sleeper.sleep(1);}}
}
改进
class BigRoom {private final Object studyRoom = new Object();private final Object bedRoom = new Object();public void sleep() {synchronized (bedRoom) {log.debug("sleeping 2 小时");Sleeper.sleep(2);}}public void study() {synchronized (studyRoom) {log.debug("study 1 小时");Sleeper.sleep(1);}}
}
好处
能显著增强程序的并发度。原本多个互不相干的功能(如睡觉、学习)因共用一把锁而互相阻塞,细分锁后,每个功能仅占用自身对应的专用锁,不同功能的线程可同时执行,互不干扰,大幅减少了不必要的等待时间,提升了整体执行效率。
坏处
会增加死锁的风险。若某个线程需要同时完成多个关联操作(例如 “先到书房拿书,再到卧室休息”),就可能需要依次获取多把锁(学习用的锁、睡觉用的锁)。一旦多个线程获取锁的顺序不一致(比如线程 A 先拿学习锁再等睡觉锁,线程 B 先拿睡觉锁再等学习锁),就会陷入互相等待对方释放锁的僵局,导致死锁。
1.10 活跃性
1.10.1 死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁。
t1 线程 获得 A 对象 锁,接下来想获取 B 对象的锁。
t2 线程 获得 B 对象 锁,接下来想获取 A 对象的锁。
例:
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {synchronized (A) {log.debug("lock A");sleep(1);synchronized (B) {log.debug("lock B");log.debug("操作...");}}
}, "t1");Thread t2 = new Thread(() -> {synchronized (B) {log.debug("lock B");sleep(0.5);synchronized (A) {log.debug("lock A");log.debug("操作...");}}
}, "t2");
t1.start();
t2.start();
1.10.2 定位死锁
检测死锁可以使用 jconsole 工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁:
cmd > jps
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
12320 Jps
22816 KotlinCompileDaemon
33200 TestDeadLock // JVM 进程
11508 Main
28468 Launcher
cmd > jstack 33200
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
2018-12-29 05:51:40
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.91-b14 mixed mode):"DestroyJavaVM" #13 prio=5 os_prio=0 tid=0x000000003525000 nid=0x2f60 waiting on condition [0x000000000000000]java.lang.Thread.State: RUNNABLE"Thread-1" #12 prio=5 os_prio=0 tid=0x00000001eb69000 nid=0xd40 waiting for monitor entry [0x00000001f54f000]java.lang.Thread.State: BLOCKED (on object monitor)at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)- waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)- locked <0x000000076b5bf1d0> (a java.lang.Object)at thread.TestDeadLock$$Lambda$2/883049899.run(Unknown Source)at java.lang.Thread.run(Thread.java:745)"Thread-0" #11 prio=5 os_prio=0 tid=0x00000001eb68800 nid=0x1b28 waiting for monitor entry [0x00000001f44f000]java.lang.Thread.State: BLOCKED (on object monitor)at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)- waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)- locked <0x000000076b5bf1c0> (a java.lang.Object)at thread.TestDeadLock$$Lambda$1/495053715.run(Unknown Source)at java.lang.Thread.run(Thread.java:745)// 略去部分输出Found one Java-level deadlock:
=============================
"Thread-1":waiting to lock monitor 0x00000000361d378 (object 0x000000076b5bf1c0, a java.lang.Object),which is held by "Thread-0"
"Thread-0":waiting to lock monitor 0x00000000361e768 (object 0x000000076b5bf1d0, a java.lang.Object),which is held by "Thread-1"Java stack information for the threads listed above:
===================================================
"Thread-1":at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)- waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)- locked <0x000000076b5bf1d0> (a java.lang.Object)at thread.TestDeadLock$$Lambda$2/883049899.run(Unknown Source)at java.lang.Thread.run(Thread.java:745)
"Thread-0":at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)- waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)- locked <0x000000076b5bf1c0> (a java.lang.Object)at thread.TestDeadLock$$Lambda$1/495053715.run(Unknown Source)at java.lang.Thread.run(Thread.java:745)Found 1 deadlock.
无代码,提取文字内容为:
- 避免死锁要注意加锁顺序
- 另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用
top -Hp 进程id
来定位是哪个线程,最后再用 jstack 排查
大功告成!