Java开发笔记(一百)线程同步synchronized
多个线程一起办事固然能够加快处理速度,但是也带来一个问题:两个线程同时争抢某个资源时该怎么办?看来资源共享的另一面便是资源冲突,正所谓鱼与熊掌不可兼得,系统岂能让多线程这项技术专占好处?果然是有利必有弊,且看之前演示售票任务时候的多线程操作,具体代码如下所示:
// 多个线程同时操作某个资源,可能会产生冲突 private static void testConflict() { // 创建一个售票任务 Runnable seller = new Runnable() { private Integer ticketCount = 100; // 可出售的车票数量 @Override public void run() { while (ticketCount > 0) { // 还有余票可供出售 ticketCount--; // 余票数量减一 // 以下打印售票日志,包括售票时间、售票线程、当前余票等信息 // 为更好地重现资源冲突情况,下面尽量拉大访问ticketCount的时间间隔 SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS"); String dateTime = sdf.format(new Date()); String desc = String.format("%s %s 当前余票为%d张", dateTime, Thread.currentThread().getName(), ticketCount); System.out.println(desc); } } }; new Thread(seller, "售票线程A").start(); // 启动售票线程A new Thread(seller, "售票线程B").start(); // 启动售票线程B new Thread(seller, "售票线程C").start(); // 启动售票线程C }
光光看代码感觉并无不妥之处,仅仅是起了三个售票线程共同卖票呗,这能有什么问题?!倘若只运行一次售票代码,倒也看不出什么名堂,可是一旦反复地多次运行这段售票代码,那么总会出现类似下列日志的意外情况,特别是在系统资源比较繁忙的时刻:
10:56:38.182 售票线程A 当前余票为97张 10:56:38.182 售票线程B 当前余票为97张 10:56:38.182 售票线程C 当前余票为97张 10:56:38.186 售票线程B 当前余票为95张 10:56:38.186 售票线程A 当前余票为95张 10:56:38.186 售票线程C 当前余票为93张 ………………………这里省略余下的日志……………………
我的天,售票日志竟然打印出了相同的余票数量,这正是多线程并发造成的结果。因为在ticketCount的自减语句和后面的日志打印语句中间还有其它代码,每行代码都需要消耗一点点的时间,哪怕是零点几毫秒,但就在这一瞬间,余票可能又被别的线程卖掉了一张,所以等到线程A打印余票日志之时,ticketCount早已被卖了不止一次。如此一来,日志打印前后的余票数量遇到不一致的情况,也就不足为奇了。
问题的症结在于余票变量ticketCount是动态变化着的,三个售票线程争先恐后地卖票,故而任一时刻的余票数量都可能发生改变。解决问题的要点自然落在余票的管控上面,正好Java提供了一个名叫synchronized的关键字,它可用来修饰某个方法或者某块代码,目的是限定该方法/代码块为同步方法/同步代码块,也就是规定同一时刻只能有一个线程执行同步方法,其它线程来了以后必须在旁边等待,直到先来的线程跑完同步方法,其它线程方可依次排队执行该同步方法。
回到之前的售票代码,第一反应是能否把售票任务的run方法设置为同步方法?与其瞎猜测,不如试试再说,于是给run方法加上关键字synchronized之后的代码片段如下所示:
// 指定整个run方法为同步方法,这样同一时刻只允许一个线程执行该方法 public synchronized void run() { while (ticketCount > 0) { // 还有余票可供出售 ticketCount--; // 余票数量减一 // 以下打印售票日志,包括售票时间、售票线程、当前余票等信息 String left = String.format("当前余票为%d张", ticketCount); PrintUtils.print(Thread.currentThread().getName(), left); } }
添加完毕再次运行售票代码,观察到了以下的售票日志:
22:46:06.733 售票线程A 当前余票为99张 22:46:06.734 售票线程A 当前余票为98张 22:46:06.735 售票线程A 当前余票为97张 22:46:06.735 售票线程A 当前余票为96张 ………………………这里省略余下的日志……………………
可见现在只剩线程A在兀自卖票,而线程B和线程C呆在一旁陪太子读书。原来synchronized给整个run方法加锁,那么只要线程A尚未结束运行,线程B和线程C就都不允许置身其中,结果便退化为只有一个线程在售票了。显然给run方法添加synchronized的做法管得太多了,其实仅有ticketCount这个余票变量会引起资源冲突,因此不妨缩小synchronized的管辖面,单单把余票减一的代码通过synchronized加以限定,并定义一个局部变量count来保存减一后的余票数值。重新修改后的售票代码片段示例如下:
public void run() { while (ticketCount > 0) { // 还有余票可供出售 int count; // 指定某个代码块为同步代码块,这样同一时刻只允许一个线程执行该段代码 synchronized (this) { count = --ticketCount; // 余票数量减一 } // 以下打印售票日志,包括售票时间、售票线程、当前余票等信息 String left = String.format("当前余票为%d张", count); PrintUtils.print(Thread.currentThread().getName(), left); } }
多次运行修改后的售票代码,观察到的售票日志终于正常打印余票数量了:
16:33:10.265 售票线程A 当前余票为99张 16:33:10.265 售票线程C 当前余票为97张 16:33:10.265 售票线程B 当前余票为98张 16:33:10.266 售票线程A 当前余票为96张 16:33:10.266 售票线程B 当前余票为94张 16:33:10.266 售票线程C 当前余票为95张 ………………………这里省略余下的日志……………………
注意到上述的同步代码块把余票数量赋值给一个局部变量,仿佛某个带返回值的方法,既然这块代码的形式与方法相像,干脆提取出来作为独立的同步方法,于是优化后的售票代码变成了下面这般:
// 把操作共享资源的代码单独提取出来作为同步方法 private static void testSyncMinMethod() { // 创建一个售票任务 Runnable seller = new Runnable() { private Integer ticketCount = 100; // 可出售的车票数量 @Override public void run() { while (ticketCount > 0) { // 还有余票可供出售 // 获得减一后的余票数量。注意getDecreaseCount是个同步方法 int count = getDecreaseCount(); // 以下打印售票日志,包括售票时间、售票线程、当前余票等信息 String left = String.format("当前余票为%d张", count); PrintUtils.print(Thread.currentThread().getName(), left); } } // 将余票数量减一,并返回减后的余票数量 private synchronized int getDecreaseCount() { return --ticketCount; // 余票数量减一 } }; new Thread(seller, "售票线程A").start(); // 启动售票线程A new Thread(seller, "售票线程B").start(); // 启动售票线程B new Thread(seller, "售票线程C").start(); // 启动售票线程C }
以上代码同样有效避免了售票之时的资源冲突,并且代码的组织结构更加清晰明了。
更多Java技术文章参见《Java开发笔记(序)章节目录》