【Java】线程的安全问题(同步代码块)

多线程可以帮我们提高效率,但是提高效率的同时它会有一个弊端:不安全。

在学习之前,我们先来看一个小练习。

一、引出问题

需求:某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票

这三个窗口在程序中应该是互相独立的,因此三个窗口就可以看做三个线程

MyThread.java

public class MyThread extends Thread {
    int ticket = 0;//0 ~ 99

    @Override
    public void run() {
        while (true) {
            //同步代码块
            if (ticket < 100) {
                try {
                    //方法执行的太快了,这里让它睡眠10毫秒
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket++;
                System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
            } else {
                break;
            }
        }
    }
}

测试类

public static void main(String[] args) {
    //创建线程对象
    MyThread t1 = new MyThread();
    MyThread t2 = new MyThread();
    MyThread t3 = new MyThread();

    //起名字
    t1.setName("窗口1");
    t2.setName("窗口2");
    t3.setName("窗口3");

    //开启线程
    t1.start();
    t2.start();
    t3.start();
}

运行上面代码,可以发现三个窗口卖的票好像是互相独立的,即三个窗口总共卖了300张票。

image-20240506170659266

但这不是我想要的,我想要的是总共只有100张票

此时需要在 ticket变量 前面加一个 static 静态关键字。

//表示这个类所有的对象,都共享ticket数据
static int ticket = 0;//0 ~ 99

但是改完后还有问题,运行代码,可以发现它还有重复的票

image-20240506170950878

并且将控制台划到最后,发现它还会超出范围。

image-20240506171217039

二、分析问题

1)为什么票会重复

假设现在有三条线程,三条线程要操作的数据刚开始为0。

线程开启后,三条线程会子在这里抢夺CPU的执行权,谁抢到了,就会继续往下执行。

image-20240506171541231

假设线程1在一开始抢到了CPU的执行权,线程1就会继续往下执行,此时是满足这里的判断条件的,所以线程1就会进入到if中,进来后立马睡10毫秒。

睡觉的时候是不会去抢夺CPU的执行权的,CPU的执行权一定会被其他线程抢走。

假设是线程2抢到了,线程2就会继续往下执行,此时也是满足这里的判断条件的,所以线程2也会进入if中,进来后,同样去睡10毫秒。

此时CPU的执行权就会被线程3抢走,线程3也会进来睡10毫秒。

image-20240506172047011

时间到了后这三条线程就会陆陆续续的醒来,继续执行下面的代码。

假设线程1醒来了,它抢夺到了CPU的执行权,线程1就会继续往下执行:ticket++,由 0 变成了 1,但是自增后,它还没来得及去打印,CPU的执行权就被线程2抢走了。

image-20240506172533061

线程2在这里也对 ticket 做了一次自增,ticket 就会从 1 变成了 2

image-20240506172841281

因此,我们需要知道一句话:线程在执行代码的时候,CPU的执行权随时有可能会被其他线程抢走。

假设现在又被线程3抢走了,线程3也做了一次自增,ticket 就变成了 3

那么在这个时候,不管是 线程1 还是 线程2 还是 线程3,继续往下打印票号的时候,它在打印的都是 3号票

image-20240506172935383

这就是重复票的由来,其根本原因是:线程执行时有随机性,CPU的执行权随时有可能会被其他的线程抢走。


2)为什么票会超出范围?

假设 ticket 现在已经到 99 了,此时三条线程在抢夺CPU的执行权。

image-20240506173255831

假设线程1抢夺到了CPU的执行权,睡10毫秒。

睡10毫秒的时候CPU的执行权被线程2抢到了,线程2也会进来睡10毫秒。

睡10毫秒的时候CPU的执行权被线程3抢到了,线程3也会进来睡10毫秒。

睡眠完后还是会陆陆续续的醒来,继续执行下面的代码。

假设现在线程1醒来了,抢到了CPU的执行权,执行 ticket自增,由 99 变成了 100

image-20240506173539157

自增完成后还是一样,还没来得及打印,CPU的执行权被线程2抢走了,线程2也做了一次自增,ticket 变成了 101

image-20240506173641189

同样的,这个时候它也还没有打印,ticket 执行权又被线程3抢走了,线程3也做了一次自增,ticket 变成了 102

那么这个时候不管是线程1,还是线程2,还是线程3,它们打印的都是 102号票

这就是超出范围的由来。

image-20240506173846189

其根本原因跟刚刚是一样的:线程执行的时候是具有随机性的,CPU的执行权随时都有可能会被其他线程抢走,这个就是买票所产生的原因。

那我们该如何纠正呢?

如果我们能将操作数据的这段代码给锁起来,当有线程进来后,其他的线程就算抢夺到了CPU的执行权,也得在外面等着,它进不来。

只有当线程1出来了,其他的线程才能进去。


三、同步代码块

把操作共享数据的代码锁起来

此时会用到一个关键字 synchronized,在它的后面写 锁对象 就行了

synchronized (){
    操作共享数据的代码
}

细节1:在最初锁的状态是默认打开的,如果有一个线程进去了,此时锁就会自动关闭。

细节2:当锁里面所有的代码都执行完毕了,线程出来了,这时候锁才会自动打开。


四、修改代码

小括号中需要写锁对象,这个锁对象是任意的,任意到我在上面创建一个Object的对象都可以,只不过我们一定要切记,这是一个锁对象,一定要是唯一的,即在对象前面加一个静态关键字就可以保证唯一了,即:MyThread这个类不管创建多少对象,这里的obj都是共享的,都是同一个。

public class MyThread extends Thread {
    //表示这个类所有的对象,都共享ticket数据
    static int ticket = 0;//0 ~ 99

    static Object obj = new Object();

    @Override
    public void run() {
        //此时我们就可以将obj放在小括号中,当做锁对象
        //这样就能解决线程安全的问题,这种解决方式我们会叫做:同步代码块
        //利用同步代码块将操作共享数据的代码给锁起来,让同步代码块里的代码轮流进行的
        while (obj) {
            synchronized (MyThread.class) {//相当于将共享数据的代码锁起来了,此时不管你有多少条线程,这个里面的代码都是轮流执行的。
                //同步代码块
                if (ticket < 100) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
                } else {
                    break;
                }
            }
        }
    }
}

五、同步代码块的两个小细节

1)synchronized同步代码块不能写在循环的外面

如果你写在循环的外面就会有一个小问题:

@Override
public void run() {
    synchronized (obj) {
        while (true) {
            //同步代码块
            if (ticket < 100) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket++;
                System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
            } else {
                break;
            }
        }
    }
}

运行上面代码,你会发现窗口1将所有的代码都卖完了,压根就没有窗口2,窗口3什么事。

image-20240506175243124

程序刚开始运行的时候,窗口1、窗口2、窗口3它会去抢夺CPU的执行权。

如果说窗口1抢到了,此时它就会进来,进来后,这里的锁就关闭了。

就算窗口2、窗口3抢到CPU的执行权,它也得在synchronized的外面等着,一直等到synchronized里面所有的代码全都执行完毕后,线程1出来了,2跟3才有可能进去。

由于synchronized里面是循环,窗口1只有将100张票全部卖完了,线程1才会出来,此时就会导致一个线程会将所有的票卖完。

@Override
public void run() {
    synchronized (obj) {
        ///----------------------------------------------------
        while (true) {
            ............
        }
    }
}

2)synchronized里面的锁对象一定要是唯一的

那如果不是唯一的,会出现什么效果呢?

如果现在有两条线程,两条线程看的是不同的锁,那么这把锁还有意义吗?

image-20240506180139784

就没意义了,如果每条线程对应的锁不一样,那这个synchronized代码块就等于没有写。

下面代码将之前的obj改成this,当线程1进来后,这里的this表示的就是线程1本身,线程2进来后,this表示的就是线程2本身,此时这里的锁对象就是不一样,我们可以来运行一下。

@Override
public void run() {
    while (true) {
        synchronized (this) {
            //同步代码块
            if (ticket < 100) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket++;
                System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
            } else {
                break;
            }
        }
    }
}

程序运行完毕,可以发现卖重复票的情况和超票的情况依旧存在。

image-20240506180454350

所以 synchronized里面的锁对象一定要是唯一的

但一般我们会怎么写呢?一般我们会在这写当前类的字节码文件对象。

其实说的就是那个class的对象,这个对象其实是唯一的,因为在同一个文件夹里面只能有一个 MyThread.class,因此字节码文件对象一定是唯一,那么既然你是唯一的,那我就把你拿过来当做是锁对象就行了。

@Override
public void run() {
    while (true) {
        synchronized (this) {
            //同步代码块
            if (ticket < 100) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket++;
                System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
            } else {
                break;
            }
        }
    }
}
  • 17
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值