一、前言
- 如果每个线程都只是做自己的事而不去打扰别人的工作,则是一种非常理想的情况。 然而现实是,线程之间通常要共享一些资源。
- 例如:火车售票系统,亿万百姓都要买票,使用多线程来并发卖票效率会高很多。
- 这里会牵扯到一个问题:就是对火车票(共享资源)的访问,同一车次、同一车厢、同一座位的票,两个人都想买怎么办?若车票就剩10张,20个人都要买怎么办?
下面我们模拟火车票的售票过程,来讨论一下线程安全问题,以及如何解决。
二、线程安全问题
我们编写一个简单的多线程售票案例,用5个线程来并发卖10张票,如下所示:
public class TicketSystem {
public static void main(String[] args) {
SellTicketTask task = new SellTicketTask();
//用5个线程来并发卖10张票
for(int i=0; i<5; i++){
new Thread(task).start();
}
}
}
class SellTicketTask implements Runnable{
//tickets:票数,设置初始值为10张票
private int tickets = 10;
@Override
public void run() {
while(true){
if(tickets > 0){ //如果票数>0的情况,则卖出票数,并输出打印剩余票数
System.out.println(Thread.currentThread().getName()+"剩余票数:"+tickets);
tickets--;
}else{ //否则代表票要卖完了,退出循环
break;
}
}
}
}
运行结果如下:
Thread-0剩余票数:10
Thread-1剩余票数:10
Thread-0剩余票数:9
Thread-3剩余票数:10
Thread-2剩余票数:10
Thread-4剩余票数:10
Thread-2剩余票数:5
Thread-3剩余票数:6
Thread-0剩余票数:7
Thread-1剩余票数:8
Thread-0剩余票数:1
Thread-3剩余票数:2
Thread-2剩余票数:3
Thread-4剩余票数:4
注意
是多个线程访问同一个资源,不能为每个线程都去new一个SellTicketTask对象
,那就成了5个线程各卖10张票了
分析和讨论
- 程序运行后,票号就乱了,正常情况应该是倒序输出票数。为什么会出现这种情况?
- 多个线程同时运行,在进入while循环后,都满足了
if(tickets > 0)
这个条件,然后打印剩余票数,此时tickers还没自减,于是重复的票数就出现了。 - 当多个线程访问同一个资源时就会产生竞争问题,如果对竞争问题不加以重视,那么就会出现如我们售票系统这样的混乱效果。
- 再举个通俗的例子:两个男人上厕所,这个厕所很特别,厕所没门也没锁,这两个男人恰好都看上了同一个蹲位,发现里头没人。一看很激动两人一起进去了,面面相觑场面一度混乱。
- 咋解决呢?比如我给厕所设置门锁,先进去的那个进厕所后锁上门,锁没开你就得外头等着。这样就不会尴尬了。
下面我们就来解决由并发访问引起的资源竞争问题。
三、解决方案1:同步语句块
为了保护对共享资源的访问,Java给出了一个synchronized
关键字,有两种用法,其中一种用法语法如下:
synchronized (synObject){ //此处synObject可以是任意的对象
//需要保护的代码
}
- 在Java中,每个对象都有一个关联的监视器(monitor),或者叫做锁
- 当一个线程访问同步语句块时,首先要锁定获得锁,之后当另一个线程访问该语句块时,就会等待。
- 当同步语句块执行完毕后,拥有锁的线程就会解锁监视器,另一个进程才可以访问同步语句块中的代码,并再次上锁
我们使用同步语句块来保护售票部分的代码,如下:
public class TicketSystem {
public static void main(String[] args) {
SellTicketTask task = new SellTicketTask();
//用5个线程来并发卖10张票
for(int i=0; i<5; i++){
new Thread(task).start();
}
}
}
class SellTicketTask implements Runnable{
//tickets:票数,设置初始值为10张票
private int tickets = 10;
//用作锁的对象
private Object o = new Object();
@Override
public void run() {
while(true){
synchronized (o){
if(tickets > 0){ //如果票数>0的情况,则卖出票数,并输出打印剩余票数
System.out.println(Thread.currentThread().getName()+"剩余票数:"+tickets);
tickets--;
}else{ //否则代表票要卖完了,退出循环
break;
}
}
}
}
}
运行结果:
Thread-0剩余票数:10
Thread-0剩余票数:9
Thread-0剩余票数:8
Thread-0剩余票数:7
Thread-0剩余票数:6
Thread-0剩余票数:5
Thread-0剩余票数:4
Thread-0剩余票数:3
Thread-0剩余票数:2
Thread-0剩余票数:1
进程已结束,退出代码为 0
分析和讨论
- 执行没有重复的票号,也没有奇怪的票号了,但是我们发现出票的速度也变慢了。
- 这是因为,任务由原来的同时执行,变成排队执行了,保证了数据安全,但是牺牲了效率
- 同时我们也发现,一直以来都是
Thread-0
线程在进行出票运作,其它线程没有参与,即便你多运行几次,都是这样的结果,这是为什么呢? - 还是举个上厕所的例子,有A、B、C、D、E这五个老爷们排着队等一个厕所,他们都尿不尽。尿完了还想尿。A来的最快,进厕所反手就是把门关上一锁,刚尿完,开门解锁那一瞬间,由于A离门最近,又把门一关一锁。BCDE只能在风中凌乱。
注意
- 同步语句块的锁对象必须是相同的的,锁不同则锁不住。
- 当使用了同步语句块后,线程访问被保护的代码会频繁地对对象进行加锁和解锁,因此会有一定的性能损耗
- 没有获得锁的线程只能等待,会影响并发执行的效率
- 所以,同步语句块的范围不要设置太大,应该只包含最关键的需要保护的资源访问代码
四、解决方案2:同步方法
sychronized
关键字的另一种用法是修饰方法,被修饰的方法称为同步方法
我们将售票代码剥离出来,放到一个私有的辅助方法中,代码如下:
public class TicketSystem {
public static void main(String[] args) {
SellTicketTask task = new SellTicketTask();
//用5个线程来并发卖10张票
for(int i=0; i<5; i++){
new Thread(task).start();
}
}
}
class SellTicketTask implements Runnable{
//tickets:票数,设置初始值为10张票
private int tickets = 10;
//用作锁的对象
private Object o = new Object();
@Override
public void run() {
while(tickets>0){
sell();
}
}
//将售票代码剥离出来,放到一个私有的辅助方法中
private synchronized void sell() {
if(tickets > 0){ //如果票数>0的情况,则卖出票数,并输出打印剩余票数
System.out.println(Thread.currentThread().getName()+"剩余票数:"+tickets);
tickets--;
}
}
}
运行结果如下:
Thread-0剩余票数:10
Thread-0剩余票数:9
Thread-0剩余票数:8
Thread-0剩余票数:7
Thread-0剩余票数:6
Thread-0剩余票数:5
Thread-0剩余票数:4
Thread-0剩余票数:3
Thread-0剩余票数:2
Thread-0剩余票数:1
进程已结束,退出代码为 0
分析和讨论
- 售票一切正常
- 同步方法和同步语句块的实现原理一样,都是对某个对象的监视器进行加锁和解锁,那同步方法使用的是哪个对象?
- 答:同步方法使用的是
this
代表对象的监视器。在本例中,就是task
对象。
注意
- 一个线程可以多次获得同一个对象的锁,如果线程在执行某个同步方法时,该方法又调用了另外的同步方法,那么这是不需要等待的,JVM会增加锁的持有计数,当线程执行完一个同步方法,计数递减为0时,锁被完全释放。此时,其他线程就可以访问同步方法了
- 静态方法也可以使用
synchronized
进行同步,同步静态方法使用的是所在类对应的class对象的锁
五、解决方案3:显示锁 Lock
在很多情况下,保证线程安全都可以使用synchronized
进行处理,但是synchronized
使用方式是先获取锁,使用后释放锁,这并不适用于所有场景。此时显示锁Lock派上用场了。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketSystem {
public static void main(String[] args) {
SellTicketTask task = new SellTicketTask();
//用5个线程来并发卖10张票
for(int i=0; i<5; i++){
new Thread(task).start();
}
}
}
class SellTicketTask implements Runnable{
//tickets:票数,设置初始值为10张票
private int tickets = 10;
//new一个显示锁l对象,并设置为公平锁
Lock l = new ReentrantLock(true);
@Override
public void run() {
while(true){
//执行代码前加锁
l.lock();
if(tickets > 0){ //如果票数>0的情况,则卖出票数,并输出打印剩余票数
System.out.println(Thread.currentThread().getName()+"剩余票数:"+tickets);
tickets--;
}else{ //否则代表票要卖完了,退出循环
break;
}
//解锁
l.unlock();
}
}
}
程序运行结果:
Thread-3剩余票数:10
Thread-4剩余票数:9
Thread-1剩余票数:8
Thread-2剩余票数:7
Thread-0剩余票数:6
Thread-3剩余票数:5
Thread-4剩余票数:4
Thread-1剩余票数:3
Thread-2剩余票数:2
Thread-0剩余票数:1
分析和讨论
- 关于
synchronized
与Lock
的区别可以看这篇文章:《Java中的Lock详解》。 - 关于
Lock
的底层实现原理可以看看这篇文章:《Java锁–Lock实现原理(底层实现)》。 - 代码中
Lock l = new ReentrantLock(true);
我们发现在new一个ReentrantLock类时,我们传了一个布尔类型的参数true
。这就是公平锁与非公平锁。如字面意思:**公平锁:**先请求获取锁的线程先获取到锁;**非公平锁:**锁被释放后,随机从等待获取锁的随机抽取一个线程获取锁。Java中默认是非公平锁,我们看看非公平锁的运行结果是如何?我们发现,程序全被0线程占用,其它线程无法插足。
Thread-0剩余票数:10
Thread-0剩余票数:9
Thread-0剩余票数:8
Thread-0剩余票数:7
Thread-0剩余票数:6
Thread-0剩余票数:5
Thread-0剩余票数:4
Thread-0剩余票数:3
Thread-0剩余票数:2
Thread-0剩余票数:1
- 关于公平锁与非公平锁可以参考这篇文章:《深入剖析ReentrantLock公平锁与非公平锁源码实现》。
注意
- synchronized是内置在JVM中的,所以它在以后的获得性能提升将会更加直接。所以在没有使用到Lock的高级功能,尽可能地使用 synchronized。
- 局部变量不会出现线程安全问题,因为局部变量在栈中,一个线程一个栈,永远不会共享。
五、总结
最后,如果有写的不对的地方,请大家多多批评指正,非常感谢!!