同步代码块比较经典的例子是火车站的售票员售票的过程,下面通过代码来分析同步代码块在这里面的作用。
package cn.sunzn.synchronize; public class SynchronizeCode { public static void main(String[] args) { new TicketSeller().start(); new TicketSeller().start(); new TicketSeller().start(); new TicketSeller().start(); } } class TicketSeller extends Thread { private static int ticket = 100; private static Object lock = new Object(); public void run() { while (true) { synchronized (lock) { /************ 每次售票前进行判断 ************/ if (ticket == 0) { break; } /************ 模拟售票的网络延迟 ************/ try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } /************ 符合条件后进行售票 ************/ System.out.println(Thread.currentThread().getName() + " 售出了第 " + ticket-- + " 张票"); } } } }
上面的代码在主线程中开启了 4 个线程,也就是同时有 4 个售票员在窗口进行售票。为了保证 4 个售票员操作的是同一张票,所以 ticket 在初始化的时候将其设置为 static , 具体再看 TicketSeller 的 run() 方法,每个售票员在进行售票之前都会去查询 ticket 的剩余数量,当 ticket 的数量等于零的时候停止售票,如果剩余票数不等于零,则进行售票。在这个过程中有 2 个操作(1:查询剩余票数;2:售票)对应了同一个 ticket,这时如果不采用同步代码块就会产生线程安全的问题。
为什么会产生线程安全问题,在分析开始之前首先要明确一个问题:那就是一个 CPU 在同一时间只能执行一个线程,体现给用户多任务的假象是通过 CPU 在各个线程之间进行高速切换来实现的。
下面来分析售票过程,因为每个售票员在进行售票前都会去查询剩余票数,如果 1
号售票员在查询完剩余票数后由于网络延迟而没有及时将所查询的票卖出,这时 CPU 切换到了 2 号售票员,2
号售票员同样对剩余票数进行查询,查询的结果里包含 1 号售票员查询过但没卖出的票,如果票还有剩余 2
号售票员会进行卖票操作并将票数减一。这时我们假设只剩下一张票,1 号售票员进行查询后发现票还有剩余,但由于网络延迟没来得及出票,这时 CPU
切换到了 2 号售票员,2 号售票员同样查询剩余票数为一,2 号售票员进行了售票操作并将票数减一,剩余票数为零。操作完毕后 CPU 切换到了 1
号售票员,由于之前 1 号售票员已经查询过剩余票数为一,所以 1 号售票员在重新获取 CPU 资源后不会重新进行剩余票数的判断,而是直接进入卖票的环节,这个过程中 1 号售票员跳过了对剩余票数为零判断的操作,最终导致卖出负数的票数。
通
过上面的分析,我们这时就会将剩余票数的查询和卖票操作统一到一起,不允许在一个售票员完成这两项操作的过程中插入另一个售票员的查询操作。这时候我们就
需要将查询和售票操作进行同步,使其原子化合为一个操作,这样对于每一个售票员来说都将 2 个操作看作了一个操作来对待。拿上面的假设为例,1
号售票员查询到剩余票数为一,等待网络延迟的过程中, CUP 切换到了 2 号售票员,由于查询和卖票进行了同步(查询和卖票在同步后具有原子性,2 个操作不可被分割),2
号售票员虽然获取到了 CPU 资源,但是无法打断 1 号售票员的操作,2 号售票员只能等待 CPU 切换到 1 号售票员处理完卖票操作。这时 1
号售票员重新获得 CPU 资源直接进行卖票操作并将票数减一,剩余票数为零。CPU 切换到 2 号售票员,2
号售票员进行剩余票数查询操作,发现剩余票数为零,则停止售票。