Java 多线程并发编程:从线程安全到锁与线程协作

📅 2026/8/24 12:05:27
Java 多线程并发编程:从线程安全到锁与线程协作
一、线程的状态Java 给线程引入了六种状态NEW创建了 Thread 对象但是还没调用start()。TERMINATED操作系统内部的线程已经销毁了但 Thread 对象还在线程的入口方法执行完毕。RUNNABLE可工作的又可以分成正在工作中和即将开始工作。接下来的三个状态表示排队等着其他事情WAITING进入阻塞状态然后死等。TIMED_WAITING带有超时时间的阻塞等待。BLOCKED特指由于锁引起的阻塞。二、线程安全问题先来看一个线程不安全的案例运行之后两个线程分别对count变量都进行 50000 次的count预计结果应该是 100000 才对可是结果却是 71783而且再多运行几次会发现这个数值是不确定的、随机性的由此便引入线程安全问题。1. 线程安全概念给出一个线程安全的确切定义是比较复杂的但我们可以这样认为某个逻辑单个线程执行没有问题但是多个线程执行出现问题这便是线程安全问题。上述的逻辑如果单线程执行那毫无疑问最终的结果一定是 100000。2. 线程安全问题的原因根本原因操作系统对于线程的调度是随机的。两个线程针对同一个变量进行修改操作。修改操作不是原子性的。内存可见性。指令重排序。分析上述代码count这行代码对应了三个 CPU 的指令load把内存中的数据加载到寄存器中。add把寄存器中的数据 1。save把寄存器中的数据写回到内存中。但是由于调度顺序是不确定的因此会有无数种情况这里随机列举四种情况3. 如何解决线程安全问题我们主要从上述提到的“修改操作不是原子的”这个原因下手。没有保证原子性会给多线程带来什么问题如果一个线程正在对一个变量操作中途其他线程插入进来了若这个操作被打断了那么结果就可能是错误的。由此便引入了“锁”从而把修改操作变成原子性的。4. synchronized 关键字Java 中引入synchronized关键字其语法格式为synchronized(object) { // 一系列代码; }synchronized会起到互斥两件事情不可能同时发生效果若某个线程执行到某个对象的synchronized中时其他线程如果也执行到同一个对象synchronized就会阻塞等待。进入synchronized修饰的代码块相当于“加锁”。退出synchronized修饰的代码块相当于“解锁”。注意此处“加锁”不是禁止线程调度而是防止其他线程插队。synchronized()括号里的对象从语法角度上来讲可以是任意的对象只要是一个 Object 子类的实例就行但是从语义角度上来讲两个线程要填写相同的对象才会产生锁竞争才会有阻塞/防止插队这样的效果。下面来看这个示例流程lock 表示“加锁”unlock 表示“解锁”其实可以用生活中的上厕所来粗略理解假设一个人进了厕所锁上门一个线程进行“加锁”其他人就要在外面等待其他线程进入阻塞等待状态至少得等厕所里的人出来后“解锁”才能允许第二个人进去第二个线程加“锁”成功可以解除阻塞状态往下执行逻辑了。下面来看加“锁”后的代码运行后这样就达到我们预期结果了。如果把整个 for 循环都放到 synchronized 关键字里是否会有问题呢运行后依旧没问题其实把整个 for 循环放到 synchronized 里面相当于就只加了一次“锁”而只把count放到 synchronized 里面就相当于循环的加了 50000 次“锁”。我们说“锁”内部的逻辑少只是一次count那么“锁”的粒度就小。“锁”内部的逻辑多50000 次的 for 循环那么“锁”的粒度就大注意这并不是代码行数的问题而是与逻辑相关。5. synchronized 其他使用示例5.1. 修饰普通方法5.2. 修饰静态方法无论哪种写法synchronized()针对啥对象“加锁”不重要重要的是两个线程是否针对同一个对象“加锁”。6. 可重入锁先来看一下下面的代码案例thread1 线程针对 count1 进行第一次“加锁”调用 add() 方法时又针对 count1 进行第二次“加锁”按照之前的逻辑这次应该会阻塞此时就无法继续执行完 add() 方法也就无法执行到第一次“加锁”的“}”从而“解锁”了那逻辑就卡住了不就成“死锁”了吗实际代码一执行是可以正常运行的其实“死锁”的逻辑是存在的但是 synchronized 针对这种情况做了特殊处理这就是锁的“可重入机制”。JVM 会记录当前持有这把锁的线程同时维护一个锁计数器同一个线程再次获取已经持有的锁时不会阻塞等待仅仅将计数器加一每退出一层 synchronized 同步块计数器就减一只有计数器归零的时候锁才真正释放其他线程才可以获取。正是这套机制避免了同一个线程自己阻塞自己的问题。分析一下上述代码外层synchronized(count1)拿到锁 → 计数器 1进入 addsynchronized(this)重入 → 计数器 2退出 add 内同步块 → 计数器 1退出外层同步块 → 计数器 0锁真正释放7. 死锁7.1 概念锁的可重入只能避免同一个线程重复获取同一把锁造成的自我阻塞并不能消除死锁。当多个线程各自占有一把锁又同时去请求对方手里的锁时就会发生真正的死锁。那什么是死锁呢多个线程互相持有对方所需要的锁彼此无限等待对方释放锁所有线程都被卡住程序无法继续执行。7.2 死锁的四个必要条件必须同时满足互斥条件锁资源同一时刻只能被一个线程占用。不可剥夺条件锁只能由持有线程主动释放其他线程不能强行抢占。请求并保持线程持有一把锁在不释放已有锁的情况下再去申请其他锁。循环等待条件线程之间形成环状等待关系A 等 B、B 等 A。只要破坏其中任意一条必要条件就可以规避死锁。7.3 死锁的代码案例运行后程序不会终止后续打印语句永远不会执行程序陷入停滞发生死锁。案例流程分析thread1 获取 locker1休眠 1 秒不释放 locker1接着申请 locker2。thread2 获取 locker2休眠 1 秒不释放 locker2接着申请 locker1。thread1 在等待 thread2 释放 locker2thread2 在等待 thread1 释放 locker1。双方都不释放已占有的锁互相无限等待程序卡死产生死锁。这个案例同时满足死锁的四个必要条件互斥条件、不可剥夺条件、请求并保持、循环等待。注意区分“可重入锁”解决的是同一个线程重复申请同一把锁无法避免这种多线程之间的死锁。补充小提示代码里 sleep 不会释放锁sleep 只是让线程休眠锁依旧握在线程手上。7.4 如何规避死锁死锁的发生需要四个必要条件同时满足只要破坏其中任意一个条件就可以避免死锁。实际开发中重点关注打破“请求并保持”与打破“循环等待”。1. 打破“请求并保持”产生问题线程已经持有一部分锁不进行释放又去申请其他新的锁。解决办法一次性申请所有需要的锁全部锁获取成功之后再执行业务逻辑不要拿到一把锁之后中途再去申请另一把锁。如果申请不到新锁就主动释放当前已经持有的锁之后再重新尝试获取锁。2. 打破“循环等待”产生问题多个线程之间形成环形等待关系互相等待对方手中的锁。解决办法给所有锁规定统一的获取顺序所有线程严格按照相同的先后顺序申请锁切断循环等待环路。举例规定永远先获取 locker1再获取 locker2。thread1、thread2 都遵守该顺序就不会出现一个持有 locker1 等 locker2另一个持有 locker2 等 locker1 的情况。8. volatile 关键字8.1 内存可见性synchronized 关键字是解决线程安全问题的一种方案但是还有一种场景需要通过 volatile 来解决。这就要谈到内存可见性了。Java 线程工作时会把变量从主内存拷贝到自己的工作内存中进行操作。修改之后不会立刻刷新回主内存。当一个线程修改了共享变量的值另一个线程看不到最新修改后的值读到的还是旧数据这就是内存可见性问题。现象举例一个线程修改标记变量另一个线程一直在循环读取该变量永远感知不到变量已经被修改循环不会停止。8.2 volatile 作用1. 保证内存可见性被 volatile 修饰的共享变量一旦某个线程对它完成修改会立刻刷新到主内存其他线程读取时直接从主内存读取最新值不再使用自己缓存里的旧副本。2. 禁止指令重排序JVM、CPU 会对指令做优化重排序volatile 可以阻止部分不合理的指令重排保证代码执行顺序符合我们编写的逻辑。重要volatile 不能保证原子性volatile 只解决“可见性、禁止重排序”不能保证操作是原子的。例如 count读取-修改-写入多步操作即便加了 volatile多线程下依旧会出现线程安全问题这种场景仍然需要 synchronized。8.3 volatile 和 synchronized 的简单对比volatile轻量级只保证可见性、禁止指令重排序不保证原子性适合标记位、状态开关这类场景。synchronized重量级锁同时保证可见性、原子性、禁止指令重排序会阻塞线程。8.4 代码案例来看下面这个代码案例运行之后现象thread1 一直在执行while(flag 0)循环thread2 通过键盘输入把 flag 修改为非 0 值thread2 打印结束。但是 thread1 不会退出循环不会打印“thread1 结束”程序卡死。原因内存可见性问题flag 是共享变量。thread1 把 flag 拷贝到自己的工作内存一直读取缓存里旧值 0。thread2 修改 flag修改写回主内存。但是 thread1 不知道主内存的 flag 已经变化不去刷新自己的工作内存一直读到旧数据 0循环永远跑下去。解决办法给共享变量 flag 加上 volatile 修饰加上 volatile 之后thread2 修改 flag会立刻刷新到主内存thread1 不会再使用本地缓存副本每次都去主内存读取最新的 flag输入非 0 后while 条件不成立thread1 正常退出循环打印结束。9. wait() 和 notify()由于线程是抢占式执行线程之间执行的先后顺序是难以预知的。但是在实际开发中有时我们希望可以人为协调多个线程的执行先后顺序。完成线程之间的协调工作主要依靠三个方法wait()、notify()、notifyAll()。注意这三个方法是属于 Object 对象的方法不是 Thread 的方法并且必须写在 synchronized 同步代码块内部调用。9.1 wait() 方法调用 wait() 会做三件事让当前线程进入等待状态暂时停止往下执行释放当前持有的锁非常关键别的线程才可以拿到这把锁执行代码当线程被唤醒之后不会立刻继续执行而是要重新去竞争获取锁拿到锁之后才会从 wait 处继续往下运行。结束等待被唤醒的几种条件其他线程调用该对象的 notify() 方法唤醒等待在这个对象上的线程如果调用带超时时间的wait(long timeout)等待时间超时会自动唤醒线程被 interrupt() 中断会抛出 InterruptedException 异常结束等待。简单说明注意wait() 一定要在 synchronized 保护的代码块中调用在锁对象上调用 wait()。如果没有持有锁就直接调用 wait程序会直接抛出 IllegalMonitorStateException 异常。9.2 notify() 方法notify() 用来唤醒调用 wait() 进入等待的线程。作用唤醒一个正在该对象上等待的线程。注意如果有多个线程在该对象上等待会随机唤醒其中某一个线程。调用 notify() 之后不会立刻释放锁要等到当前 synchronized 代码块执行完毕退出同步块之后锁才会释放被唤醒的线程才可以去竞争获取锁继续执行。如果当前没有任何线程在等待调用 notify() 不会报错只是什么事情都不做。9.3 notifyAll() 方法作用唤醒所有在该对象上处于等待状态的线程。唤醒之后所有被唤醒的线程会进入锁竞争大家互相争抢这一把锁同一时刻也只能有一个线程拿到锁执行其余没抢到的继续阻塞。注意notify() vs notifyAll() 小结notify()随机唤醒一个等待线程。notifyAll()唤醒全部等待线程。9.4 代码案例运行后9.5 wait() 与 sleep() 的区别经典面试题1. 锁处理不同wait()在等待的时候会释放持有的锁其他线程可以获取锁执行sleep()休眠的时候不会释放锁如果已经拿到锁休眠期间锁仍然被自己占有别的线程拿不到锁。2. 所属类型不同wait()Object 的成员方法sleep()Thread 类的静态方法。3. 使用位置要求wait()必须放在 synchronized 同步代码块/同步方法中调用sleep()可以在任意地方使用不需要加锁。4. 唤醒方式wait()可以被 notify() / notifyAll() 主动唤醒也可以设置超时自动唤醒也可以被 interrupt 中断sleep()时间到自动结束休眠也可以被 interrupt() 中断抛出异常不能被 notify 唤醒。实际上sleep 只是单纯让线程歇一会wait 是用来做线程之间条件协调通信的。