JavaEE 第4节 线程安全问题

小贴士:

本节题目所述的主题其实非常的庞大,如果要细讲起来,一篇博客远远不够,本篇博客只会每个方面的内容做一个简要描述详细的内容在后续同专栏博客中都会涉及到的,如果有需要可以一步到本专栏的其他博客

正文开始:

一、什么线程安全问题?

示例演示:

这里用一个直观的代码来展示一个经典的线程安全问题:

public class demo1 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        //分别通过两个线程对count++

        t1.start();
        t2.start();
        t1.join();//join方法的作用是主线程运行到这了,先等待t1线程结束,在回来运行主线程。
        t2.join();//t2线程同理

        System.out.println("count="+count);//输出最后的count值
    }
}

按照正常的逻辑,count值因该是10000,可是:

很奇怪😕

这实际上就是出现了线程安全问题。

是什么原因导致这些问题的呢?

我们接下来将会详细讲解。

二、造成线程安全问题的主要原因

主要原因:

1、操作系统对线程的调度,在程序运行角度看是“随机的”(抢占式执行)

2、代码结构,即多个线程同时修改同一个变量。

3、修改变量这个操作不是原子性*的。

4、指令重排列(Instruction Reordering,后序章节详细讲解)

5、内存可见性(Memory Visibility,后续章节详细讲解

6、线程饿死(Thread Starvation,后续章节详细讲解

7、死锁(Deadlock,后序章节详细讲解)


注:
原子性*的意思是对于一个操作,结果只能是做了和没做两种状态,不能出现第三种状态。

刚才示例出现线程安全问题的原因就是1、2、3点导致的:

在代码中我们知道t1和t2两个线程时并发执行的,并且都对count变量进行++操作

而在CPU的视角看,count++操作要分成三步(不是原子的):

1)load:把count对应的内存数据写入寄存器。

2)add:逻辑运算单元对数据进行++操作。

3)save:把新的值重新写入count变量的内存。

t1和t2两个线程并发执行,都在不断按照上面这三步指令执行,在系统“随机”调度的过程中就很可能出现这样一种情况:

某一时刻,t1和t2同时load了count的内存数据,并且两个线程load的count值时一样的,然后他们分别对count++,最后写入内存(save)。会过头来我们发现,在这两个线程都运行完一次后,count只进行了一次++操作!


在深入问大家一个问题,程序中的count有没有可能小于5000呢?

答案是可能的

可能的情况举例:

t1和t2都只能对count++5000次,倘若又这样一个情况,t1刚开始被调度,读取到的count值是0,然后由于抢占式执行,t2开始被调度并且被多次连续调度,导致最后t2线程执行了4999次,之后t1又开始被调度把count=0写回原来的内存(形成了覆盖),然后t2又被调度了把count=0读取到逻辑运算单元,这是又由于抢占式执行,t2停止运作,t1开始被连续调度执行了5000,count被修改成了5000

现在只剩下t2还没有执行了,t2把count=0(在t2的逻辑运算单元上)++,对count进行覆写,count竟然还变成了1!

三、线程安全问题的解决办法

1、给线程加锁

像刚才的示例,我们可以通过设置多个变量的方式进行解决:

public class demo1 {
    public static int count = 0;
    public static int count1=0;
    public static int count2=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count1++;
            }
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count2++;
            }
        });
        //分别通过两个线程对count++
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        count=count1+count2;
        System.out.println("count="+count);
    }
}

不过这不是JAVA解决线程安全问题的主流方式,了解即可。

在JAVA中,主流解决线程安全问题的方式是对线程进行“加锁”的操作。

什么是锁?

刚才讲解线程安全问题的原因时,我们提到了原子性、修改同一个变量这两个关键字,这里所说的锁实际上就是把一些非原子性的程序“锁”起来,让它变成原子性的,这样线程安全问题就被解决了。

比如刚才的t1和t2线程,都对count进行++操作。但是由于系统的“随机”调度,两个线程的load、add、save操作是相互穿插进行的,数据的修改很可能会出错。
而现在把[load、add、save]这个非原子性的++操作进行“上锁”,保证要么++操作成功,要么什么都没有操作,既++操作变成了原子性的。
通过锁的这种操作,两个线程的【load、add、save】🔒就不可能穿插执行了,因为必须完成【】🔒内的操作,才能去执行另一个线程的任务。
这样线程安全问题就得到了解决。

synchronized关键字(加锁的工具)

synchronized基本用法:

Java提供了 synchronized 关键字 (监视器锁-monitor lock)来完成加锁操作。

接下来通过synchronized关键字,解决上面的线程安全问题:

public class demo1 {
    public static int count = 0;
    public static Object object1 = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (object1) {//括号内填写一个实例对象,任何类性的对象都是可以的!!
                    count++;
                }
            }
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {//括号内填写一个实例对象,任何类性的对象都是可以的!!
                synchronized (object1) {//代码块中填写,需要原子化的程序。
                    count++;
                }
            }
        });
        //分别通过两个线程对count++
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("count=" + count);
    }
}

此时运行结果count=10000

synchronized除了上述写法,还可以通过修饰静态方法或者成员方法的方式,实现加锁:

public class Threads {
    static int count = 0;

    //实现加锁
    private synchronized static void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {

        Thread thread1=new Thread(()->{
            for (int i = 0; i <5000 ; i++) {
                add();
            }
        });
        Thread thread2=new Thread(()->{
            for (int i = 0; i <5000 ; i++) {
                add();
            }
        });

        thread1.start();
        thread2.start();
        Thread.sleep(1000);//这样可以极大增大 先让thread1和thread2两个线程先执行完,在打印count的概率
        System.out.println(count);
    }
}

成员方法是同理的,这里就不做过多演示了。

synchronized的一些基本特性:
   1)互斥(Mutual Exclusion)

进入synchronized代码块内,相当于上锁
退出synchronized代码块,相当于 解锁
对于同一个对象,如果一个线程上了锁,那么其他线程必须等待这个线程解锁,才能运行:

锁外其他的线程就处在BLOCK的等待状态。


图中的同一个对象是什么意思?

在刚才的代码演示中,t1和t2两个线程的synchronized括号里,填写的都是同一个对象。

如果两个线程填写不同的对象,跟没加锁没有区别,最后的count大概率也不可能等于一万。

也就是说,对于同一个对象加锁,锁对于一个线程来说才是有效的,或者说是存在的。

比如,我们对上面的代码进行简单的修改,t1线程和t2线程两个锁对象不同:

public class demo1 {
    public static int count = 0;
    public static Object object1 = new Object();
    public static Object object2 = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (object1) {
                    count++;
                }
            }
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (object2) {
                    count++;
                }
            }
        });
        //分别通过两个线程对count++
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("count=" + count);
    }
}

其中一个运行结果:

2)可重入(Reentrant)

如果重复对同一个线程进行这种加锁会怎么样:

  Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (object1) {//第一次锁了
                    synchronized(object1){//第二次我在锁?
                        count++;
                    }
                }
                
            }
        });

我们来慢慢分析:

第一次加锁:

本来t1线程可以安全的上侧所的,但是他还不放心,于是synchronized代码块里,又上了一次锁:

所以根据上面的逻辑,重复对一个线程针对同一个对象加锁是会出现锁被“焊死”的情况的(也就是死锁

这种重复锁导致的死锁,只会出现在C++\Python等其他编程语言中,Java不会,因为synchronized关键字会对这个情况进行判断,不会对相同对象的相同线程进行重复上锁

具体代码举例:

//先清楚标志位,然后抛出异常
public class Threads {
    static int count = 0;

    //实现加锁
    private synchronized static void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            synchronized (Threads.class){
                synchronized (Threads.class){
                    System.out.println("在第二个锁的内部");
                }
            }
        });
        Thread.sleep(1000);
        thread.start();
        thread.join();

        System.out.println("thread线程结束");

    }

}

以上代码在逻辑上是错误的,因为对同一个对象同一个线程重复上锁了,但是程序并没有卡主:

原因就是synchronized关键字会自动识别是重复上锁,如果有只会上锁一次。

那么如果没有synchronized的这个特性,程序会怎么样呢?

如图:

程序将会永远的停留在第22行和23行之间,在第22行第一次上锁后,程序需要等待第一次锁的解锁,才能在23行位置进行在次上锁,这样就形成了一个逻辑闭环,循环依赖,永远无法退出!(C++\Python等这些语言就有可能出现这种状况)


额外知识补充:

synchronized关键字是JVM提供的功能,synchronized底层实现就是依靠JVM中C++代码调用操作系统的API来实现的。而这些操作系统的API又是通过CPU上特殊的指令来实现上锁、解锁的。

2、volatile关键字

这个关键字是专门解决内存可见性问题的,这里不做过多解释,同专栏后续博客有详细讲解。

3、wait和notify方法

这两个方法是Object类自带的,用于解决线程饿死问题,这里不做过多解释,同专栏后续博客有详细讲解。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值