JAVA-线程不安全的原因?如何解决?

需要了解线程知识的可以看我这几篇博客:

什么是进程线程-优快云博客

JAVA-Thread类实现多线程_java中线程的实现方式之集成thread类-优快云博客

JAVA-多线程join()等待一个线程-优快云博客

目录

一、线程安全的概念

二、多线程带来的风险(重点) 

 三、线程不安全的原因

1.线程是随机调度的

 2.多个线程,同时修改同一变量

3.修改操作不是原子的 

3.1 synchronized 

 3.2 synchronized的多种写法

3.2.1任意对象

3.2.2 类对象

3.2.3 synchronized修饰普通方法 

3.2.4 synchronized修饰静态方法

3.3 可重入锁

3.4 死锁 

3.4.1 死锁的必要条件

3.4.2 死锁场景 经典哲学家就餐问题

4.可见性 

4.1 如何解决可见性问题

4.1.1 加开销稍大的操作

 4.1.2 volatile

5.指令重排序

5.1 饿汉设计模式

5.2 懒汉模式

5.3 这两种设计模式是否是线程安全的

5.4 解决线程不安全问题

3.解决指令重排序问题


一、线程安全的概念

如果多线程环境下运行的代码的结果是符合我们预期的,并且在单线程和多线程执行的结果都相同,则说这个程序是线程安全的。

二、多线程带来的风险(重点) 

我们用两个线程同时去执行count相加的工作,希望能够得出1万

    private static int count;

    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++;
            }
        });

        //创建线程并执行
        t1.start();
        t2.start();

        //主线程等待一下t1 t2线程
        t1.join();
        t2.join();

        System.out.println(count);
    }

结果:

我们发现跟预期完全不一样,而且差很多,我们再运行几次试试。

发现每次运行的结果都不一样,而且这太可怕了。

如果我们是在银行上班,这如果是客人的钱,哪谁还敢在我们银行存钱?

 

原因:

此处代码中的count++操作,其实在cpu视角看来,是3个指令

(1)load:把内存中的数据,读取到cpu寄存器里

(2)add:把cpu寄存器里的数据 + 1

(3)save:把寄存器的值,写回内存

但是不同cpu指令集可能不同,这里只是举例理解:

 三、线程不安全的原因

1.线程是随机调度的

线程是随机调度的
这是线程安全问题的 罪魁祸⾸
随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数.
程序猿必须保证在任意执⾏顺序下 , 代码都能正常⼯作
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
           while (true) {
               System.out.println("hello t1");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });

        Thread t2 = new Thread(() -> {
            while (true) {
                System.out.println("hello t2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t3 = new Thread(() -> {
            while (true) {
                System.out.println("hello t3");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();
        t3.start();
        
        t1.join();
        t2.join();
        t3.join();
    }

而且这是操作内核的操作,我们做为应用层的程序员是无法干预的。

 2.多个线程,同时修改同一变量

这个在线程不安全的原因已经详细解释过了。我们依然无法做到同一时间修改同一变量。

3.修改操作不是原子的 

例如刚刚的count++;就不是原子的背后是load add save 三个指令,包括 +=、-=、/=、*=……

 我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。

那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。
有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。

java就提供了一个关键字用来加锁

3.1 synchronized 

java中提供了synchronized()关键字,来完成加锁操作。

虽然synchronized关键词带有括号但它并不是方法,( ) 里需要指定一个锁对象,来进行后续的判断。()里可以是如何类型的对象。

 我们用上面的count++的例子来理解:

    private static int count;
    private static Object locker = new Object();

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

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

        //创建线程并执行
        t1.start();
        t2.start();

        //主线程等待一下t1 t2线程
        t1.join();
        t2.join();

        System.out.println(count);
    }

 结果:

为什么这次正确了?

我们在非原子操作语句中加上了synchronized

在指令层面的原理:

(1)加锁会有一个lock指令

(2)出锁会有一个unlock指令

这样就避免了count++的指令被拆分了。

锁对象的作用,就是来区分,多个线程是否是针对 “同一对象” 加锁:

  • 如果是针对同一个对象加锁,此时就会出现“阻塞”(锁竞争/锁冲突)
  • 不是针对同一个对象加锁,此时就不会出现“阻塞”,多个线程仍然是并发执行的

不知道看了上述的描述大家是不是觉得这跟串行执行有什么区别?

其实我们只是在少量代码中(同一时间要改变同一变量的值)的逻辑变为了“串行执行”,多数逻辑中仍然是并行执行的,所以仍然是比单线程快很多的。

 3.2 synchronized的多种写法

3.2.1任意对象
synchronized (locker) {
    count++;
}
3.2.2 类对象
synchronized (Demo7.class) {
    count++;
}

编写的java文件就是.java的文件,通过javac编译成.class文件。jvm运行的时候,把class文件加载到内存中,形成了对应的类对象。一个java进程中,一个类的类对象是只有一个的。

类对象也是对象当然也就可以写在synchronized()里面了。

3.2.3 synchronized修饰普通方法 
    private static int count;
    private static Object locker = new Object();

    synchronized private void add() {
            count++;
    }

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

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

        //创建线程并执行
        t1.start();
        t2.start();

        //主线程等待一下t1 t2线程
        t1.join();
        t2.join();

        System.out.println(count);
    }

结果:

这样的写法类似于:

    private void add() {
        synchronized (this) {
            count++;
        }
    }
3.2.4 synchronized修饰静态方法
    synchronized private static void add() {
        count++;
    }

静态方法没有this,那它就类似于:

    private static void add() {
        synchronized (Demo7.class) {
            count++;
        }
    }

3.3 可重入锁

void func() {

    synchronized(this) {
        //这个锁会怎么样?
        synchronized(this) {
        
        }
    }

}

思考一下,当我们进入第一个锁的时候,再次进入另一个锁是否会发生阻塞?

还是之前的count++例子:

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

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

        //创建线程并执行
        t1.start();
        t2.start();

        //主线程等待一下t1 t2线程
        t1.join();
        t2.join();

        System.out.println(count);
    }

 结果:

我们发现还是可以输出结果并且是正确的

java引入了可重入锁,同一线程可以入锁多次,为了避免不小心写了多个锁的情况:

  • 允许同一线程多次获取锁
  • 基于计算器的重入机制
  • 避免自我阻塞与死锁

3.4 死锁 

大家可以看这样一段代码:

    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 对locker1 加锁完成");

                synchronized (locker2) {
                    System.out.println("t1 对locker2 加锁完成");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                System.out.println("t2 对locker2 加锁完成");

                synchronized (locker1) {
                    System.out.println("t1 对locker1 加锁完成");
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

    }

结果:

程序一直没有结束,进入了阻塞:

我们可以通过jconsole观察:

3.4.1 死锁的必要条件
  • 锁是互斥的【锁的基本特性】
  • 锁是不可被抢占的线程1拿到了锁A,如果线程1不主动释放A,线程2不能把锁抢过来【锁的基本特性】
  • 请求和保持。线程1,拿到锁A之后,不释放A的前提下,去拿锁B【代码结构】
  • 循环等待/环路等待/循环依赖。多个线程获取锁的过程,存在循环等待,刚刚举例的就是【代码结构】
3.4.2 死锁场景 经典哲学家就餐问题

有5个哲学家,5根筷子吃同一碗面,需要拿起两根筷子才可以吃面,但是哲学家又非常固执,只要拿起了筷子,没吃着面绝对不会放下筷子

如果我们不进行干预,他们都拿到了一根筷子,那谁都吃不到,谁都不会放下【阻塞了】

那我们可以给哲学家进行一个编号,编号大的一个可以先拿起右手边的筷子,拿了右手的筷子才可以去拿左手边的筷子:

第一次5号哲学家拿起了两根筷子,先吃了面并且放下筷子。

此时1号哲学家就可以拿起右手的筷子,4号哲学家可以拿起左手的筷子,依次循环

 每个哲学家都对应着一个线程

4.可见性 

内存可见性问题,本质上,是编译器/JVM对代码的优化的时候,优化出BUG。如果是单线程的,编译器/JVM,代码优化一般都是非常准确的,优化之后,不会影响到逻辑。但如果是多线程的可能就会出现一些问题。

 

    public static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //只要不为0就结束循环
            while (n == 0) {
                //什么都不写
            }
            System.out.println("t1 线程已结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数");
            n = sc.nextInt();
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }

我们想用 t2 线程来控制 t1 线程是否终止。

但是我们来看结果:

逻辑上没有问题,那为什么t1线程没有结束?

此时JVM执行这个操作发现,每次执行(1)的操作开销都非常大(相比(2)来说),而且每一次比较都一样呀~,并且JVM并没有并没有意识到未来可能会改变n的值,这个时候JVM就做了一个大胆的决定,它把(1)操作给优化掉了,每次只和寄存器上的数据进行比较。

但是t2线程修改n的值放到内存中,但是此时t1每次循环,不会真的读取内存中的值,此时对于t1来说,n的改变是“不可变的”

4.1 如何解决可见性问题
4.1.1 加开销稍大的操作
    public static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //只要不为0就结束循环
            while (n == 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 线程已结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数");
            n = sc.nextInt();
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }

 结果:

为什么这个时候没有优化了?

将内存上的内容读取到寄存器的操作,相比之下远远小于sleep,那这个时候再进行优化就杯水车薪了。

很明显这不是一个好的办法,有的时候对程序的速度要求很高,因为这个问题就浪费了100ms,就太不值得了

 4.1.2 volatile

加上volatile关键字,就是在提示编译器,表示这个变量是“易变”

public static volatile int n = 0;

引入了volatile的时候,编译器生成这个代码的时候,就会在读取这个变量的操作附件生成一些特殊的指令,称为“内存屏障”。后续JVM执行到这些特殊的指令,就知道了,不能进行上述优化

    public static volatile int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //只要不为0就结束循环
            while (n == 0) {
                //什么都不写
            }
            System.out.println("t1 线程已结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数");
            n = sc.nextInt();
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }

结果:

5.指令重排序

在此之前我想给大家介绍一种设计模式

《单例设计模式》是用于一些类特别大,例如:100G,一个程序只支持实例化一个对象的类。那么这个时候我们设计类的时候就可以采用《单例模式》。

《单例设计模式》我们主要介绍一下主要的两种:

1.饿汉设计模式 2.懒汉设计模式

5.1 饿汉设计模式

“饿” 就代表着急迫的意思,只要程序一加载,就会创建实例

//单列模式中的:饿汉模式
class Singleton {
    //将实例写为static,程序一启动就会创建实例
    private static Singleton instance = new Singleton();


    //每次通过getInstance来获取对象
    public static Singleton getInstance() {
        return instance;
    }

    //将构造方法设为私有的,就不能通过new 关键字实例化对象了
    private Singleton() {

    }


}

public class demo18 {
    public static void main(String[] args) {
        //此时编译器就报错了
        Singleton singleton = new Singleton();
        
        //只能通过这个方法获取实例对象
        Singleton singleton1 = Singleton.getInstance();
    }
}

此时就会提醒你说这个方法是所有的


5.2 懒汉模式

“懒” 在计算机中是褒义词,代表效率高,它往往代表着,加载数据只加载目前需要的一部分,不会一下加载全部数据,那么就代表着效率高。

//单列模式中:懒汉模式
class SingletonLazy {

    private static SingletonLazy instance = null;

    //私有的构造方法,保证外部无法实例对象
    private SingletonLazy() {

    };
    
    private static SingletonLazy getInstance() {
        //instance是否为null
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
}
    

当你需要这个对象的时候才开始创建。目前我们只是用懒汉设计模式的思想实现了一小部分

 

5.3 这两种设计模式是否是线程安全的

观察一下这两种设计模式是否线程安全?

因为:

5.4 解决线程不安全问题

1.那更具上述的描述,我们可以直接用synchronized解决

//单列模式中:懒汉模式
class SingletonLazy {
    private static SingletonLazy instance = null;
    private static Object locker = new Object();

    private SingletonLazy() {

    };

    private static SingletonLazy getInstance() {
        //instance是否为null
        synchronized (locker) {
            if (instance == null) {
                instance = new SingletonLazy();
            }
        }
        
        return instance;
    }
}

加锁的同时解决了线程安全,但是会带来阻塞,而且有一些没有必有的阻塞,当我们创建了实例后,还有必要进行阻塞判断吗?当然是没有必要的。

2.解决效率问题

//单列模式中:懒汉模式
class SingletonLazy {
    private static SingletonLazy instance = null;
    private static Object locker = new Object();

    private SingletonLazy() {

    };

    private static SingletonLazy getInstance() {
        //instance是否为null
        if (instance == null) {
            synchronized (locker) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }


        return instance;
    }
}

这个时候可能大部分人都认为没有问题了,其实还有一个问题。我们的主题 “指令重排序”

3.解决指令重排序问题

而且这个问题非常可怕哦,不会抛出异常,而是在未来的使用中出问题,所有这一项一定要考虑到,那这个时候其实我们也可以加volatile关键字。

private static volatile SingletonLazy instance = null;

此时编译器围绕这个变量的优化就会非常克制

所以volatile关键字可以解决

  1. 可见性问题
  2. 指令重排序问题(针对赋值)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值