线程
操作系统中线程的状态
Java 线程的状态
NEW
:Thread 被 new 创建,但是还没有调用 start() 方法;RUNNING
:调用 start() 方法,线程开始执行,只有此阶段才会获得 CPU 资源;BLOCKED
:遇到 synchronized 关键字,等待获得锁,线程进入阻塞状态;WAITING
:Object.wait()、Object.join() 线程进入等待状态;TIME_WAITING
:调用 Thread.sleep(long) Object.wait(long) 后线程进入超时等待状态,被提前唤醒或超时时间结束,线程重新进入 RUNNING 状态;TERMINATED
:线程完成执行,进入终止状态。
sleep 和 wait 的区别是什么?
在 Java 中,sleep()
和 wait()
看似都能让线程“停下来”,但它们的作用场景和机制完全不同。核心区别如下:
sleep()
:不会释放锁,如果一个线程在同步代码块中执行Thread.sleep()
,它依然持有锁,其他线程无法进入临界区。wait()
:必须在同步块(synchronized
)中调用调用后线程会释放当前持有的对象锁,并进入对象的等待队列,直到被唤醒(notify()
或notifyAll()
)。
它们的使用常见也不相同:
sleep()
:用于让当前线程“休眠一段时间”,比如定时任务、限流、模拟延迟。wait()
:用于线程间通信和协作,比如经典的生产者–消费者模型。
什么是线程安全?
什么是死锁?
死锁(Deadlock)是并发编程中一种常见的问题,指两个或多个进程(或线程)在执行过程中,由于竞争资源或相互等待,形成一种“循环等待”的状态。如果没有外力干预,这些进程将永远无法继续执行。
死锁的四个必要条件(只有同时满足,才可能发生死锁):
互斥条件:至少有一个资源只能被一个进程占用,不能被共享。例如:某个打印机一次只能被一个进程使用。
请求与保持条件:一个进程至少已经持有了一个资源,同时又提出了新的资源请求,而新的资源被其他进程占有,该进程阻塞但保持已有资源不释放。
不剥夺条件:进程已获得的资源,在未使用完之前,不能被强行剥夺,只能由进程主动释放。
循环等待条件:存在一个进程—资源的循环等待链,例如:
进程 A 占有资源 1,请求资源 2;
进程 B 占有资源 2,请求资源 3;
进程 C 占有资源 3,请求资源 1。
ThreadLocal
JDK 提供的 ThreadLocal 可以实现让每个线程都有自己的专属本地变量,虽然每个线程操作的是同一个 ThreadLocal 对象,但是 get()
和 set()
方法操作的变量都保存在每个线程内部,和其他线程互不影响。
举个例子,下面的代码创建了 10 个线程对一个 ThreadLocal 对象进行自增,并输出自增后的值。
// 创建一个 ThreadLocal 对象,内部持有一个 Integer 变量,初始值是 0
static ThreadLocal<Integer> local = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
// 每个线程都对 ThreadLocal 中的 Integer 变量加一再存回
int val = local.get() + 1;
local.set(val);
System.out.println(Thread.currentThread().getName() + ", val = " + local.get());
}, "Thread-" + i).start();
}
Thread.sleep(500); // 等待 500ms
// 主线程获取值,输出默认值 0
System.out.println(Thread.currentThread().getName() + ", val = " + local.get());
}
输出如下,10 个线程的输出都是 1,主线程输出 0,因为主线程没有进行自增。验证了 ThreadLocal 对于每个线程都是独立的副本。
Thread-1, val = 1
Thread-4, val = 1
Thread-2, val = 1
Thread-3, val = 1
Thread-0, val = 1
Thread-7, val = 1
Thread-6, val = 1
Thread-5, val = 1
Thread-8, val = 1
Thread-9, val = 1
main, val = 0
Thread 的原理
知其然还要知其所以然,相信不少人都好奇 ThreadLocal 明明只是一个普通的对象,为什么它能做到“每个线程都有一份独立的副本”?
首先从 Thread
类的源码说起。在 java.lang.Thread
中,有一个名为 threadLocals
的成员变量:
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
}
这个变量的类型是 ThreadLocalMap
,它是 ThreadLocal
的内部静态类,是一个定制的哈希表结构,专门用于保存当前线程的所有 ThreadLocal 对象及其对应的值。
也就是说,每个线程都有一个独立的 ThreadLocalMap
,这个 map 的键是 ThreadLocal
实例本身,值则是我们调用 set()
时传入的对象。
我们再来看 ThreadLocal
的核心方法 set()
:
public class ThreadLocal<T> {
public void set(T value) {
Thread t = Thread.currentThread(); // 获取当前线程
ThreadLocalMap map = getMap(t); // 获取当前现线程中的 ThreadLocalMap
if (map != null) {
// 插入/更新键值对
map.set(this, value); // key 是当前 ThreadLocal 实例,val 是 set() 的值
} else {
createMap(t, value); // map 不存在,创建 map 并插入值
}
}
// 获取线程 t 中的 threadLocals 对象
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// 为线程 t 创建 ThreadLocalMap
// key 是当前 ThreadLocal 实例,val 是 set() 的值
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
}
通过这段源码我们可以清楚地看到,每个线程的 ThreadLocalMap
是独立存在的,ThreadLocal
实际上并不直接保存值,而是作为 key 被存入当前线程的 threadLocals
。
所以即使多个线程共享同一个 ThreadLocal
实例,它们操作的也是各自线程中的不同 ThreadLocalMap
,从而实现了线程隔离。
锁升级了解吗
Java 在并发场景下,为了兼顾性能与安全,引入了锁升级机制。同一把锁会根据竞争情况,从低开销状态逐步升级到高开销状态,以减少无意义的阻塞与线程切换。
Java 对象的锁标志是存储在对象头中的 Mark Word (在 64 位虚拟机中长度是 64 位)中的:
倒数第3位:表示该对象是否可偏向;
倒数两位:锁标志位(Lock Flag)。
一、对象创建:可偏向状态
在 JDK 6 之后默认开启偏向锁,且过了启动延迟(默认 4 秒,可通过 -XX:BiasedLockingStartupDelay=0
取消延迟)后,新创建的对象在 Mark Word 中的最后三位标志位是 101
,表示 可偏向(Biased)。此时对象还没有被加锁,Mark Word 中:
线程 ID(54 位):为空(未偏向到具体线程);
Epoch/GC 年龄等(7 位):GC 用于年龄计数;
标志位(3 位):101,偏向锁标记
二、第一次加锁:进入偏向锁
当某个线程第一次对这个对象执行 synchronized
时:
JVM 会把该线程的 ID 写入对象的 Mark Word。
标志位依旧是
101
,但状态变为“已偏向到某个线程”。
此时加锁/解锁都不需要 CAS 操作,只需判断对象头中的线程 ID 是否等于当前线程 ID,非常快。
相等:直接进入同步块(无额外开销);
不等:意味着有其他线程竞争,需要进入下一步处理。
三、其他线程竞争:撤销偏向锁 → 无锁状态
如果有另一个线程尝试获取这把锁,JVM 会:
暂停持有锁的线程(安全点):JVM 在执行某些全局性操作(如 GC、偏向锁撤销)时,需要所有线程都到达“可安全暂停”的位置,这些位置称为安全点。暂停后可以安全地修改对象头信息而不影响正在执行的线程。
撤销偏向锁:清除 Mark Word 中的线程 ID,修改标志位为
001
(无锁,可偏向标志位为 0);
这样,对象就回到了无锁状态。偏向锁只适合单线程反复加锁解锁的场景,一旦出现竞争,保留线程 ID 已经没有意义。
四、无锁状态加锁:轻量级锁
当对象处于无锁状态时,如果有线程执行 synchronized
:
线程会在栈帧中创建 锁记录(Lock Record),把对象头中的 Mark Word 复制到锁记录中;
使用 CAS + 自旋 尝试将对象的 Mark Word 替换为指向锁记录的指针。
CAS 成功:获得 轻量级锁;
CAS 失败(默认 10 次失败):说明有其他线程持有锁,会升级为重量级锁并阻塞线程,避免 CPU 空转浪费。
五、轻量级锁竞争:升级为重量级锁
如果轻量级锁的 CAS 失败(意味着已经有其他线程持有锁),JVM 会将锁升级为 重量级锁(Monitor):
对象 Mark Word 存储的是指向 Monitor 对象的指针。
竞争失败的线程会调用
park()
挂起,进入 Monitor 的阻塞队列(EntryList)。持有锁的线程释放锁时,会调用
unpark()
唤醒等待队列中的线程。
重量级锁保证了高竞争下的安全性,但会涉及用户态/内核态切换和线程调度,性能开销最大。
补充:JDK 15 移除偏向锁
在 JDK 15 之后取消了偏向锁,第一次加锁直接走轻量级锁流程(CAS 尝试获取锁)。主要是因为偏向锁的收益越来越低,而维护它的复杂度和成本反而不小。
偏向锁在 JDK 6 引入时,是为了优化同一线程反复获取同一把锁的场景。但是现在的服务端、云原生应用几乎都是高并发场景,锁一开始就会有竞争,偏向锁还没发挥作用就被撤销了。撤销偏向锁是有成本的(安全点、批量重偏向/撤销),在竞争场景下反而变成性能负担。
轻量级锁、锁消除等优化手段已经足够应对低竞争场景,维护偏向锁的复杂逻辑不再划算,因此 JDK 15 默认关闭并废弃,JDK 18 彻底移除。