【Java】线程安全问题的理解

一、前言

  • 如果每个线程都只是做自己的事而不去打扰别人的工作,则是一种非常理想的情况。 然而现实是,线程之间通常要共享一些资源。
  • 例如:火车售票系统,亿万百姓都要买票,使用多线程来并发卖票效率会高很多。
  • 这里会牵扯到一个问题:就是对火车票(共享资源)的访问,同一车次、同一车厢、同一座位的票,两个人都想买怎么办?若车票就剩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只能在风中凌乱。

注意

  1. 同步语句块的锁对象必须是相同的的,锁不同则锁不住。
  2. 当使用了同步语句块后,线程访问被保护的代码会频繁地对对象进行加锁和解锁,因此会有一定的性能损耗
  3. 没有获得锁的线程只能等待,会影响并发执行的效率
  4. 所以,同步语句块的范围不要设置太大,应该只包含最关键的需要保护的资源访问代码

四、解决方案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对象。

注意

  1. 一个线程可以多次获得同一个对象的锁,如果线程在执行某个同步方法时,该方法又调用了另外的同步方法,那么这是不需要等待的,JVM会增加锁的持有计数,当线程执行完一个同步方法,计数递减为0时,锁被完全释放。此时,其他线程就可以访问同步方法了
  2. 静态方法也可以使用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

分析和讨论

  • 关于synchronizedLock 的区别可以看这篇文章:《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

注意

  1. synchronized是内置在JVM中的,所以它在以后的获得性能提升将会更加直接。所以在没有使用到Lock的高级功能,尽可能地使用 synchronized。
  2. 局部变量不会出现线程安全问题,因为局部变量在栈中,一个线程一个栈,永远不会共享。

五、总结

在这里插入图片描述
最后,如果有写的不对的地方,请大家多多批评指正,非常感谢!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值