Java 多线程基础与锁机制|进程线程、synchronized、volatile、Lock 面试全梳理

0 阅读10分钟

大家好,这里是后端基础拾光集。

前面我们把 Java 基础语法、内部类、Lambda 全部梳理完了,现在进入 Java 并发核心板块:多线程基础 + 锁机制。并发是 Java 面试的必考重难点,也是我们平时业务开发中常用到的技术,很多小伙伴分不清进程与线程、synchronized 与 volatile、各种锁的种类和适用场景。今天这篇我们一起从基础来学习一下,先举个例子:

把操作系统想象成一家餐厅:

  • 进程 = 餐厅里的一个个独立厨房。每个厨房有自己独立的灶台、食材柜、冰箱(独立内存空间)。厨房之间互相隔离,A 厨房的食材 B 厨房拿不到,要传递只能通过"传菜口"(进程间通信 IPC)。
  • 线程 = 同一个厨房里的多个厨师。他们共享同一个厨房的灶台、食材柜、冰箱(共享进程内存),但每个人有自己的围裙和手边的小工具(线程私有栈、程序计数器)。

同一个厨房多个厨师(多线程)同时操作同一口锅(共享变量),就会抢资源,出现并发安全问题。锁,就是用来规定:同一时间,锅只能交给一个厨师操作。

对应到技术

类比技术概念关键点
独立厨房进程独立内存空间,隔离性好,创建/切换开销大
同一厨房的厨师线程共享进程内存,切换开销小,需同步
传菜口IPC管道、消息队列、共享内存、Socket
厨师各自的小工具线程私有栈、PC栈、程序计数器、本地方法栈私有

面试要点:线程是 CPU 调度的最小单位,进程是资源分配的最小单位。线程共享堆和方法区,私有栈、PC、本地方法栈。共享带来效率,也带来竞态条件——这正是所有并发问题的根源。

重点:进程是资源分配最小单位;线程是 CPU 调度最小单位。

一、进程 vs 线程

  1. 进程:独立资源空间,进程之间互相隔离,通信成本高。
  2. 线程:隶属于进程,同一进程下所有线程共享堆、方法区;每个线程独有自己的虚拟机栈、程序计数器。

一句话:进程是独立包厢,线程是包厢里面干活的服务员。

代码:创建线程的 4 种方式

  1. 继承 Thread 类
public class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println("线程执行");
    }
    public static void main(String[] args) {
        new MyThread().start(); // start()开启新线程,不是直接调用run()
    }
}

注意:调用run()只是普通方法执行;start()才会向操作系统申请创建新线程。

  1. 实现 Runnable 接口(推荐,避免单继承限制)
class Task implements Runnable{
    @Override
    public void run() {
        System.out.println("runnable任务");
    }
}
public class Demo {
    public static void main(String[] args) {
        new Thread(new Task()).start();
    }
}
  1. Callable + FutureTask:可以获取返回值、抛异常
  2. 线程池创建(生产环境首选!)

开发规范:业务代码不要手动 new Thread,优先线程池。

二、线程核心状态

线程一共 6 种状态:NEW → RUNNABLE → BLOCKED / WAITING / TIMED_WAITING → TERMINATED

继续餐厅类比:

  • NEW:服务员招聘好了,还没上岗。
  • RUNNABLE:待命,可以干活(不一定正在被 CPU 执行,等待调度)
  • BLOCKED:抢锁失败,排队等待拿锁(synchronized 锁)
  • WAITING:主动休息,无限等待,需要别人唤醒(wait ())
  • TIMED_WAITING:限时休息,睡一段时间自动醒来(sleep)
  • TERMINATED:任务完成,下班。

高频面试区分:sleep () 和 wait () 区别

  1. sleep 是 Thread 方法;wait 是 Object 方法
  2. sleep 不会释放锁;wait 会释放持有的锁
  3. sleep 是限时;wait 不指定时间会无限等待,需要 notify 唤醒

三、并发问题根源(三大要素)

两个厨师同时伸手去拿同一口锅(共享变量),一个在炒菜一个在盛汤,结果菜糊了——这就是竞态条件(Race Condition) 。

三大并发特性,缺一不可:

  1. 原子性:一个操作不可分割。i++ 看似一步,实际是"读-改-写"三步,中间可能被打断。
  2. 可见性:共享资源,一个线程改了共享变量,另一个线程要能立刻看到。CPU 缓存会导致看不到。
  3. 有序性:编译器和 CPU 会指令重排优化,单线程没问题,多线程可能乱套。

三个条件同时满足,就会出现线程安全问题。 锁,就是用来解决这三个问题。

四、锁分类与原理

1. synchronized 同步锁(内置锁、悲观锁)

类比:厨房只有一口锅,给这口锅上一把锁,规定谁拿到锅钥匙谁才能用,用完归还。其他人排队等——这就是 synchronized 的互斥。 可以修饰方法 / 代码块。

  • 修饰实例方法:锁对象是当前 this 实例
  • 修饰静态方法:锁对象是类的 Class 对象
  • 修饰代码块:锁是我们手动指定的对象

三种用法

public class Kitchen {
    private int food = 0;

    // 1. 修饰实例方法:锁的是当前对象 this(这个厨房)
    public synchronized void cook() {
        food++;
    }

    // 2. 修饰静态方法:锁的是 Class 对象(整个厨房类型)
    public static synchronized void clean() { }

    // 3. 修饰代码块:锁指定对象(最灵活,推荐)
    public void serve() {
        synchronized (this) {
            food--;
        }
    }
}

底层原理:每个 Java 对象头里有个 Mark Word,记录锁状态。synchronized 的锁升级过程:

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

  • 偏向锁:只有一个厨师用这口锅,记下他的名字,下次直接进,无需加锁。
  • 轻量级锁:有第二个厨师来了,升级,用 CAS 自旋抢锁(不阻塞,转圈等)。
  • 重量级锁:抢的人多、自旋太久,升级为操作系统层面的互斥量,抢不到就阻塞挂起(不占 CPU,但唤醒开销大)。

✅优点:JDK 自带,简单,自动释放锁,不会死锁(异常自动释放)

❌缺点:锁竞争激烈的时候,重量级锁会阻塞,性能下降

2. volatile 关键字(不是锁!)

很多面试踩坑:volatile不是锁,只能保证可见性、禁止指令重排,不能保证原子性。 类比:厨师 A 改了菜谱,但只写在自己脑子里(CPU 缓存),厨师 B 看不到。volatile 相当于规定:菜谱一改,必须立刻写到中央公告板上,所有人每次都去公告板读。

它保证什么

public class Flag {
    private volatile boolean running = true;  // 不加 volatile,可能死循环

    public void stop() { running = false; }

    public void work() {
        while (running) {  // 每次从主内存读,能及时看到修改
            // do something
        }
    }
}
  • ✅ 保证可见性:写立即刷主内存,读强制从主内存读。
  • ✅ 保证有序性:通过内存屏障禁止指令重排。
  • ❌ 不保证原子性!volatile int i; i++ 依然线程不安全。

适用场景:状态标记位(开关),双重检查锁 DCL 单例。

经典应用:双重检查锁单例

public class Singleton {
    private static volatile Singleton instance;  // volatile 不能少!

    public static Singleton getInstance() {
        if (instance == null) {              // 第一次检查,避免每次都加锁
            synchronized (Singleton.class) {
                if (instance == null) {      // 第二次检查,防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么必须 volatile?  new Singleton() 分三步:分配内存 → 初始化对象 → 引用指向内存。指令重排可能变成"分配内存 → 引用指向内存 → 初始化",此时另一个线程第一次检查发现 instance != null,拿到一个半成品对象。volatile 禁止这个重排。

3. Lock 显式锁(ReentrantLock)

JDK 层面实现的锁,手动加锁、手动解锁。

private final ReentrantLock lock = new ReentrantLock();

public void cook() {
    lock.lock();           // 可中断、可超时、可公平
    try {
        // 临界区
    } finally {
        lock.unlock();     // 必须 finally 释放!
    }
}

特点:

  • ReentrantLock 必须手动 unlock(放 finally),忘记 unlock 会永久死锁。
  • ReentrantLock 支持公平锁、可中断、超时获取、多条件变量(Condition)。
  • 复杂场景用 ReentrantLock。

4. CAS 与乐观锁:不锁也能安全

不锁锅,而是规定:盛汤前先看一眼锅是不是你刚才那口、汤量没变,变了就重来——这就是 CAS(Compare And Swap)。

AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();  // 底层 CAS 循环,无锁线程安全

CAS 三大问题:

  1. ABA 问题:值从 A→B→A,CAS 以为没变。解决:加版本号 AtomicStampedReference。
  2. 自旋开销:竞争激烈时空转耗 CPU。
  3. 只能保证一个变量原子性。

5. 其他锁概念(面试常问)

  • 可重入锁:同一个线程拿到锁之后,可以再次获取同一把锁。synchronized、ReentrantLock 都是可重入锁。类比:同一个服务员拿到盘子锁之后,可以再次拿这把锁,不会自己卡死自己。
  • 公平锁 vs 非公平锁:公平锁,排队先来先得;非公平锁,来了可以直接抢,吞吐量更高(ReentrantLock 默认非公平)
  • 悲观锁:默认一定会有人抢资源,访问直接上锁。synchronized、ReentrantLock 属于悲观锁。
  • 乐观锁:默认没人抢,不加锁,更新的时候校验版本号,冲突重试。典型:CAS、数据库版本号。

6. 一张表理清

分类维度锁类型说明
是否阻塞悲观锁 / 乐观锁悲观:先加锁再操作(synchronized);乐观:先操作后校验(CAS)
竞争策略公平锁 / 非公平锁公平按排队顺序,非公平可插队(默认非公平,吞吐高)
重入性可重入锁同一线程可重复获取(synchronized、ReentrantLock)
读写分离共享锁 / 排他锁读锁共享,写锁排他(ReadWriteLock)
锁状态偏向/轻量/重量synchronized 的升级路径
实现隐式锁 / 显式锁synchronized(隐式)vs Lock 接口(显式,需手动 unlock)

⚠️开发高频踩坑点

坑 1:count++ 不是原子操作! count++ 分为 3 步:读取 → +1 → 写回。多线程不加锁,一定会丢数据。volatile 解决不了。多线程必须用 AtomicInteger 或加锁。

坑 2:synchronized 锁错对象 静态方法锁 Class;实例方法锁 this。如果锁的对象变了,锁直接失效。

坑 3:忘记在 finally 释放 Lock 锁,发生异常直接死锁。

坑 4:不要在锁内调用外部方法或做 IO,可能长时间持锁或引发死锁。

坑 5:锁粒度过大性能差,过小可能失效,临界区尽量只包必要代码。

坑 6:双重检查锁单例必须加 volatile。

坑 7:死锁四大条件(面试必考)

  1. 互斥:资源同一时间只能一个线程持有
  2. 持有并等待:线程拿到 A 锁,等待 B 锁,不释放 A
  3. 不可剥夺:别人不能强行抢走线程手里的锁
  4. 循环等待:线程 1 等线程 2 资源,线程 2 等线程 1 资源

解决思路:破坏任意一条。最常用:统一锁的获取顺序。

🧪面试高频考点

Q1:synchronized 和 Lock 的区别?

A:

  1. synchronized JVM 内置,自动释放;Lock 是 API,手动 lock/unlock
  2. Lock 支持公平锁、可中断、超时 tryLock;synchronized 不支持
  3. 锁升级后 synchronized 性能已经很好,简单场景优先 synchronized

Q2:volatile 能保证原子性吗?

A:不能。仅可见性 + 禁止重排,count++ 这种复合操作无法保证原子性。

Q3:什么是锁升级?

A:synchronized 在 JDK1.6 引入锁优化。无锁→偏向锁→轻量级锁(CAS 自旋)→重量级锁(操作系统互斥),只能升级,不能降级。

Q4:sleep () 和 wait () 区别?

A:前面讲过,sleep 不释放锁,wait 释放锁;sleep 属于 Thread,wait 属于 Object,必须在同步代码块调用。

✅总结避坑清单

  1. 生产环境不要手动 new Thread,优先线程池。
  2. 简单同步优先synchronized;需要公平锁、超时、中断,选 ReentrantLock。
  3. volatile 保可见和有序、不保原子,只适合状态标记,不能用于计数、自增。
  4. 使用 Lock,unlock 必须写在 finally。
  5. 死锁:保证多个锁获取顺序一致,避免循环等待。
  6. 锁分类记牢:悲观/乐观、公平/非公平、可重入、读写、隐式/显式。
  7. synchronized 三者全保,靠对象头 Mark Word 锁升级(偏向→轻量→重量)。
  8. CAS 是无锁乐观实现,注意 ABA 和自旋开销。

📝本文属于专栏【Java 后端基础拾光集】,生活化讲解 Java 基础,持续更新面试知识点。 公众号【Java后端程序媛手记】同步更新 Java 底层原理、后端线上踩坑实录,系列文章整理成合集,方便系统学习。