线程

操作系统中线程的状态

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)是并发编程中一种常见的问题,指两个或多个进程(或线程)在执行过程中,由于竞争资源或相互等待,形成一种“循环等待”的状态。如果没有外力干预,这些进程将永远无法继续执行。

死锁的四个必要条件(只有同时满足,才可能发生死锁):

  1. 互斥条件:至少有一个资源只能被一个进程占用,不能被共享。例如:某个打印机一次只能被一个进程使用。

  2. 请求与保持条件:一个进程至少已经持有了一个资源,同时又提出了新的资源请求,而新的资源被其他进程占有,该进程阻塞但保持已有资源不释放。

  3. 不剥夺条件:进程已获得的资源,在未使用完之前,不能被强行剥夺,只能由进程主动释放。

  4. 循环等待条件:存在一个进程—资源的循环等待链,例如:

    1. 进程 A 占有资源 1,请求资源 2;

    2. 进程 B 占有资源 2,请求资源 3;

    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 会:

  1. 暂停持有锁的线程(安全点):JVM 在执行某些全局性操作(如 GC、偏向锁撤销)时,需要所有线程都到达“可安全暂停”的位置,这些位置称为安全点。暂停后可以安全地修改对象头信息而不影响正在执行的线程。

  2. 撤销偏向锁:清除 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 彻底移除。

Lock 的原理