前面介绍了如何通过线程同步来避免多线程并发的资源冲突问题,然而添加synchronized的方式只在简单场合够用,在一些高级场合就暴露出它的局限性,包括但不限于下列几点:
1、synchronized必须用于修饰方法或者代码块,也就是一定会有花括号把需要同步的代码给包裹起来。这样的话,花括号内外的变量交互比较麻烦,特别是同步代码块,多出来的花括号硬生生把原来的代码隔离开,只好通过局部变量来传递数值。
2、synchronized的同步方式很傻,一旦同步方法/代码块被某个线程执行,其它线程到了这里就必须等待前个线程的处理,要是前个线程迟迟不退出同步方法/代码块,那么其它线程只能傻傻的一直等下去。
3、synchronized无法判断当前线程处于等待队列中的哪个位置,等待队列要是很长的话,也许走另外一条分支更合适,但synchronized是个死脑筋,它不知道等待队列的详细情况,也就无从选择更优的代码路径。
为此Java又设计了一套锁机制,通过锁的对象把加锁和解锁操作分离开,从而解决同步方式的弊端。锁机制提供了好几把锁,最常见的名叫可重入锁ReentrantLock,所谓可重入,字面意思指的是支持重新进入,凡是遇到被当前线程自身锁住的代码,则仍然允许进入这块代码;但要是遇到被其它线程锁住的代码,则不允许进入那块代码。换句话说,加锁不是为了锁自己,加锁是为了锁别人,故而可重入锁又称作自旋锁,之前介绍的synchronized也属于可重入机制。下面是ReentrantLock相关的锁方法说明:
lock:对可重入锁加锁。
unlock:对可重入锁解锁。
tryLock:尝试加锁。加锁成功返回true,加锁失败返回false。该方法与lock的区别在于:lock方法会一直等待加锁,而tryLock要求立刻加锁,要是加锁失败(表示之前已经被其它线程加了锁),就马上返回false,一会都等不了。
isLocked:判断该锁是否被锁住了。
getQueueLength:获取有多少个线程正在等待该锁的释放。
回到售票线程的例子,现在把同步方式改为加锁解锁的实现,修改后的售票代码示例如下:

	// 创建一个可重入锁
private final static ReentrantLock reentrantLock = new ReentrantLock(); // 测试通过可重入锁避免资源冲突
private static void testReentrantLock() {
Runnable seller = new Runnable() {
private Integer ticketCount = 100; // 可出售的车票数量 @Override
public void run() {
while (ticketCount > 0) { // 还有余票可供出售
reentrantLock.lock(); // 对可重入锁加锁
int count = --ticketCount; // 余票数量减一
reentrantLock.unlock(); // 对可重入锁解锁
// 以下打印售票日志,包括售票时间、售票线程、当前余票等信息
String left = String.format("当前余票为%d张", count);
PrintUtils.print(Thread.currentThread().getName(), left);
}
}
};
new Thread(seller, "售票线程A").start(); // 启动售票线程A
new Thread(seller, "售票线程B").start(); // 启动售票线程B
new Thread(seller, "售票线程C").start(); // 启动售票线程C
}

以上采用锁机制的代码,运行起来没什么问题。可是实际业务往往不会这么简单,比如售票员在售票前还要帮旅客挑选合适的行程,这样又会消耗一定时间。通过编码演示的话,可在售票之前打开某个磁盘文件,模拟售票前的准备工作。于是添加模拟代码后的run方法变成了下面这副模样:

			public void run() {
while (ticketCount > 0) { // 还有余票可供出售
int count = 0;
// 根据指定路径构建文件输出流对象
try (FileOutputStream fos = new FileOutputStream(mFileName)) {
reentrantLock.lock(); // 对可重入锁加锁
count = --ticketCount; // 余票数量减一
reentrantLock.unlock(); // 对可重入锁解锁
fos.write(new String(""+count).getBytes()); // 把字节数组写入文件输出流
} catch (Exception e) {
e.printStackTrace();
}
// 以下打印售票日志,包括售票时间、售票线程、当前余票等信息
String left = String.format("当前余票为%d张", count);
PrintUtils.print(Thread.currentThread().getName(), left);
}
}

接着运行上述的模拟代码,在售票日志中经常发现以下的负数余票:

………………………这里省略前面的日志……………………
17:12:06.568 售票线程C 当前余票为3张
17:12:06.569 售票线程B 当前余票为2张
17:12:06.569 售票线程A 当前余票为1张
17:12:06.570 售票线程B 当前余票为0张
17:12:06.570 售票线程A 当前余票为-1张
17:12:06.570 售票线程C 当前余票为-2张

明明每次循环之前都有判断余票数量要大于零,为啥还会出现车票被卖到负数的情况?真是咄咄怪事。原来在循环开始之后到对余票减一之间,多了一个打开文件的步骤,正是因为文件的打开操作耗费了一点点时间,导致其它线程在这一瞬间卖掉车票,而当前线程以为还有余票可卖,其结果必然导致卖出了早就卖光的车票。譬如当前线程在循环开始前检查余票数量为1,认为有票可卖,于是开始给旅客选择车票,谁知别的线程刚好在这空挡卖掉最后一张票,那么实时的余票数量减少到0,可是当前线程浑然不知,继续后面的选票与售票操作,最终又卖掉了一张票,此时余票数量刷新为-1。显然在每次循环开头检查余票不够保险,还得在选票之后售票之前再检查一次,务必确保还有余票才能进行售票操作。
鉴于检查余票和售出车票的性质有所不同,检查余票不会更改余票变量,所以它属于读操作;而售出车票会更改余票变量,所以它属于写操作。理论上可以同时进行读操作,但不能同时进行写操作。更具体地说,A线程在读的时候,B线程允许读但不允许写;A线程在写的时候,B线程既不允许读也不允许写。据此可将锁再细分为读锁和写锁两类,读锁与读锁不是互斥关系,而读锁与写锁是互斥关系,且写锁与写锁也是互斥关系。总而言之,检查余票这项操作适用于读锁,售出车票这项操作适用于写锁。
Java提供的读写锁工具名叫ReentrantReadWriteLock,意即可重入的读写锁,调用读写锁对象的readLock方法可获得读锁对象,调用读写锁对象的writeLock方法可获得写锁对象,之后再根据实际情况分别对读锁或者写锁进行加锁和解锁操作。利用读写锁优化之前的售票逻辑,主要开展以下两点修改:
1、在售票(余票数量减一)这一步骤的前面加上写锁,该步骤后面解除写锁。
2、售票之前补充检查余票的判断语句,并在检查步骤的前面加上读锁,该步骤后面解除读锁。
通过读写锁优化修改后的完整售票代码如下所示:

	// 创建一个可重入的读写锁
private final static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 获取读写锁中的写锁
private final static WriteLock writeLock = readWriteLock.writeLock();
// 获取读写锁中的读锁
private final static ReadLock readLock = readWriteLock.readLock(); // 测试通过读写锁避免资源冲突
private static void testReadWriteLock() {
Runnable seller = new Runnable() {
private Integer ticketCount = 100; // 可出售的车票数量 @Override
public void run() {
while (ticketCount > 0) { // 还有余票可供出售
int count = 0;
// 根据指定路径构建文件输出流对象
try (FileOutputStream fos = new FileOutputStream(mFileName)) {
readLock.lock(); // 对读锁加锁。加了读锁之后,其它线程可以继续加读锁,但不能加写锁
if (ticketCount <= 0) { // 余票数量为0,表示已经卖光了,只好关门歇业
fos.close(); // 关闭文件
break; // 跳出售票的循环
}
readLock.unlock(); // 对读锁解锁
writeLock.lock(); // 对写锁加锁。一旦加了写锁,则其它线程在此既不能读也不能写
count = --ticketCount; // 余票数量减一
writeLock.unlock(); // 对写锁解锁
fos.write(new String(""+count).getBytes()); // 把字节数组写入文件输出流
} catch (Exception e) {
e.printStackTrace();
}
// 以下打印售票日志,包括售票时间、售票线程、当前余票等信息
String left = String.format("当前余票为%d张", count);
PrintUtils.print(Thread.currentThread().getName(), left);
}
}
};
new Thread(seller, "售票线程A").start(); // 启动售票线程A
new Thread(seller, "售票线程B").start(); // 启动售票线程B
new Thread(seller, "售票线程C").start(); // 启动售票线程C
}

运行上面的读写锁售票代码,从打印的售票日志中再也找不到余票为负数的情况了,可见读写锁很好地解决了盲目售票的问题。

………………………这里省略前面的日志……………………
16:29:44.899 售票线程C 当前余票为3张
16:29:44.899 售票线程B 当前余票为2张
16:29:44.899 售票线程A 当前余票为1张
16:29:44.900 售票线程C 当前余票为0张

  

更多Java技术文章参见《Java开发笔记(序)章节目录

Java开发笔记(一百零一)通过加解锁避免资源冲突的更多相关文章

  1. Java开发笔记(一百零四)普通线程池的运用

    前面介绍了线程的基本用法,以及多线程并发的问题处理,但实际开发中往往存在许多性质相似的任务,比如批量发送消息.批量下载文件.批量进行交易等等.这些同类任务的处理流程一致,不存在资源共享问题,相互之间也 ...

  2. Java开发笔记(一百零三)线程间的通信方式

    前面介绍了多线程并发之时的资源抢占情况,以及利用同步.加锁.信号量等机制解决资源冲突问题,不过这些机制只适合同一资源的共享分配,并未涉及到某件事由的前因后果.日常生活中,经常存在两个前后关联的事务,像 ...

  3. Java开发笔记(一百零七)URL地址的组成格式

    URL的全称是Uniform Resource Locator,意思是统一资源定位符,俗称网络地址或网址.网络上的每个文件及接口,都有对应的URL网址,它规定了其他设备如何通过一系列的路径找到自己,犹 ...

  4. Java开发笔记(一百零九)XML报文的定义和解析

    前面介绍了JSON格式的报文解析,虽然json串短小精悍,也能有效表达层次结构,但是每个元素只能找到对应的元素值,不能体现更丰富的样式特征.比如某个元素除了要传输它的字符串文本,还想传输该文本的类型. ...

  5. Java开发笔记(一百零二)信号量的请求与释放

    前面介绍了同步与加锁两种并发处理机制,虽然加锁比起同步要灵活一些,但是加锁在某些高级场合依然力有未逮,包括但不限于下列几点:1.某块代码被加锁之后,对其它线程而言就处于繁忙状态,缺乏弹性的阈值范围:2 ...

  6. Java开发笔记(一百零五)几种定时器线程池

    前面介绍了普通线程池的用法,就大多数任务而言,它们对具体的执行时机并无特殊要求,最多是希望早点跑完早点出结果.不过对于需要定时执行的任务来说,它们要求在特定的时间点运行,并且往往不止运行一次,还要周期 ...

  7. Java开发笔记(一百零六)Fork+Join框架实现分而治之

    前面依次介绍了普通线程池和定时器线程池的用法,这两种线程池有个共同点,就是线程池的内部线程之间并无什么关联,然而某些情况下的各线程间存在着前因后果关系.譬如人口普查工作,大家都知道我国总人口为14亿左 ...

  8. Java开发笔记(一百二十五)AWT图像加工

    前面介绍了如何使用画笔工具Graphics绘制各种图案,然而Graphics并不完美,它的遗憾之处包括但不限于:1.不能设置背景颜色:2.虽然提供了平移功能,却未提供旋转功能与缩放功能:3.只能在控件 ...

  9. Java开发笔记(一百二十六)Swing的窗口

    前面介绍了AWT界面编程的若干技术,在编码实践的时候,会发现AWT用起来甚是别扭,它的毛病包括但不限于下列几点:1.对中文的支持不好,要想在界面上正常显示汉字,还得在运行时指定额外的运行参数“-Dfi ...

随机推荐

  1. 响应式Web设计- 背景图片

    背景图片可以响应式调整大小或缩放,以下是三种不同的方式 1.如果 background-size 属性设置为 "contain", 背景图片将按比例自适应内容区域.图片保持其比例不 ...

  2. Paxos算法与Zookeeper分析,zab (zk)raft协议(etcd) 8. 与Galera及MySQL Group replication的比较

    mit 分布式论文集 https://github.com/feixiao/Distributed-Systems wiki上描述的几种都明白了就出师了 raft 和 zab 是类似的,都是1.先选举 ...

  3. GIMP图像窗口的自定义

    具体功能包含:初始缩放比例.空格键按下时触发动作

  4. inotify+rsync sersync+rsync实时同步服务

    中小型网站搭建-数据实时的复制-inotify/sersync inotify是一种强大的,细粒度的.异步的文件系统事件监控机制(软件),linux内核从2.6.13起,加入inotify支持,通过i ...

  5. '>>' should be '> >' within a nested template argument list

    在编译关于opencv相机标定的工程的时候出现了这个问题 vector<vector<Point3f>>  objectPoints;  error: 'objectPoint ...

  6. loj2143 「SHOI2017」组合数问题

    大傻逼题--就是求 \(nk\) 个元素选出一些元素,选出的元素的个数要满足模 \(k\) 余 \(r\),求方案数. 想到 \(\binom{n}{m}=\binom{n-1}{m-1}+\bino ...

  7. ASP.NET(一):Reques对象和Response对象的区别,以及IsPostBack属性的用法

    导读:在ASP.NET的学习中,初步认识了其6大对象(严格说来只能算是属性):Request,Response,Application,Session,Server,OjectContext.这些对象 ...

  8. .NET重构(六):删除用户和结账的理解

    导读:这是第二回机房了,第一回不明不白,不清不楚的就过去了(相对),这一回,有了新的发现.就是在用户删除的时候,涉及到的一些逻辑问题,以及结账时的数据来源问题. 一.用户删除 问题:第一次机房,包括重 ...

  9. 公钥密码之RSA密码算法扩展欧几里德求逆元!!

    扩展欧几里得求逆元 实话说这个算法如果手推的话问题不大,无非就是辗转相除法的逆过程,还有一种就是利用扩展欧几里德算法,学信安数学基础的时候问题不大,但现在几乎都忘了,刷题的时候也是用kuangbin博 ...

  10. 九度oj 题目1533:最长上升子序列

    题目描述: 给定一个整型数组, 求这个数组的最长严格递增子序列的长度. 譬如序列1 2 2 4 3 的最长严格递增子序列为1,2,4或1,2,3.他们的长度为3. 输入: 输入可能包含多个测试案例. ...