[Java EE] 多线程(三):线程安全问题(上)

1. 线程安全

1.1 线程安全的概念

如果多线程环境下代码运行的结果不符合我们的预期,则我们说存在线程安全问题,即程序存在bug,反之,不存在线程安全问题.

1.2 线程不安全的原因

我们下面举出一个线程不安全的例子:我们想要在两个线程中对count进行++操作

java 复制代码
public class Demo9 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(count);
    }
}

运行结果如下:

但是这里我们预期的结果是100000,这里我们看到,实际结果和预期结果相差甚远,这便是产生了线程安全问题,使得程序出现了bug,我们要想解决上述的bug,我们必须先了解清楚bug产生的原因.

  1. 线程调度是随机的
    这是线程安全问题的罪魁祸首
    随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数.
    程序猿必须保证在任意执行顺序下,代码都能正常工作.
    某个线程在执行指令的过程中,当他执行任何一个指令的时候,都有可能被其他线程抢占走CPU.
  2. 修改共享数据
    多个线程同时修改同一个变量.上面的代码中,就都是针对count进行修改.
  3. 原子性
    在前面,我们有给大家提到过事务的原子性,大家还记得我们的助教迪卢克姥爷吗?
    在这里,多线程的原子性其实和事务的原子性大相径庭.我们在这里首先要理解什么是多线程中的原子性:

有请助教:达达利亚,钟离

达达利亚和钟离都到了一台ATM机前来取钱,现在每一台ATM机前都有一个门,一把锁,当达达利亚进去之后,门就会自动上锁,这样钟离便不会对达达利亚取钱的过程造成干扰,在达达利亚取完钱之前,钟离只可以在外面排队等待,在达达利亚取完钱之后,钟离才可以进入.也就是在tread线程对count进行修改的时候,tread1线程不可以对tread修改count的过程进行干扰,这便保证了原子性.反之如果钟离对达达利亚取钱的过程造成了干扰,这便不保证原子性.

一条Java语句不一定是原子的,也不一定是一条指令 :

我们回到线程这里,那么如果拿上面这个存在线程安全问题的代码(不保证原子性的代码),那么他的底层原理是什么样子的呢:

  • 首先tread和tread1同时读到count=0
  • tread线程对count进行++之后放入内存之后,count变为1
  • tread1对线程进行++之后,对上一个count=1的值进行了覆盖,count还是1.
  • 这便会引起bug
  1. 内存可见性
  2. 指令重排序
    后续介绍

1.3 解决线程安全问题

要想解决线程安全问题,我们必须要从原因来入手:

  • 从原因一入手:这是多线程已有的特性,无法干预.
  • 从原因二入手:这是一个切入点,**但是不普适,只针对特殊的场景可以做到,**比如String把变量设置为不可变对象,就是为了保证线程安全问题.在对上一个String进行修改的时候,其实在底层又new了一个新的String,修改的实际上不是同一个变量.
  • 从原因三入手:这是一个普适性比较高的切入点,我们想象,我们是否也可以有一把向ATM机那样的锁,来保证线程的原子性呢,答案是有.我们可以使用synchronized关键字来对线程进行上锁.通过上锁操作来把非原子的操作打包为一个原子的操作.保证tread线程对count计算的结果写入内存中在tread1线程读取内存中的count之后,使得它们呈现串行化执行.
  • 从原因四和五入手,后续介绍.

1.4 synchronized关键字--->监视器锁

为了解决上述线程安全问题,我们使用synchronized对上述代码的线程进行加锁:

java 复制代码
public class Demo11 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();//锁对象
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o) {//线程上锁
                    count++;
                }
            }
        });
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o){//拿到的都是o锁,产生锁互斥
                    count++;
                }
            }
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(count);
    }
}
  • 首先,什么是锁:
    锁本质上是一个OS提供的功能,通过API给到了应用程序,JVM再对这样的API进行包装.这里我们就可以把锁简单地理解为一个不管类型,不管名字,不管是否存在泛型的任意变量,作用上有且只有一个,就是用来区分两个线程知否针对同一个对象加锁.
  • 如何对线程上锁:
    在一个线程中,在某一行使用synchronized ( )关键字,并在括号中传入锁对象,就证明从这一行的{开始,就开始对线程进行了上锁,直到}解锁.

当我们了解完synchronized的第一个特性之后,我们就知道上述上锁的过程是怎么回事了.

1.4.1 synchronized的使用实例

  1. 修饰代码块
java 复制代码
public class SynchronizedDemo {
   private Object locker = new Object();
 
   public void method() {
  	 synchronized (locker) {
 //一系列操作
 	  }
   }
}
  1. 锁当前对象
java 复制代码
public class SynchronizedDemo {
	 public void method() {
	 synchronized (this) {
 
		}
	}
}
  1. 直接修饰普通方法
java 复制代码
public class SynchronizedDemo {
	 public synchronized void methond() {
	 }
}

一旦有线程调用该方法,就会上锁.

  1. 修饰静态方法
java 复制代码
public class SynchronizedDemo {
 	public synchronized static void method() {
	 }
}

1.4.2 synchronized的特性

  1. 互斥性与锁竞争
    在tread线程对count进行++的时候,在count++的外围,我们使用synchronized关键字对count++进行了包裹,由于tread线程启动比tread1早,也就是在此时,线程tread已经拿到了o这把锁.此时由于tread1线程也在RUNNABLE状态,它也想拿到o这把锁.但是发现,o这把锁已经被tread线程占用了,只能阻塞等待,等待tread解锁 .tread1进入BLOCKED状态.此时锁就产生了互斥性 .
    解锁之后,由于系统调度线程的随机性 ,tread和tread1继续竞争o锁,便会产生锁竞争 .

我们举个例子来说明:

有请助教: 小乔,周瑜,兰陵王

由于兰陵王比周瑜先到一步,所以小乔先和兰陵王贴贴 了一段时间.

兰陵王完事之后,兰陵王对小乔解锁,但是兰陵王又觉得自己还没有和小乔贴贴够,但是周瑜又向进去和自己的爱人贴贴,此时兰陵王和周瑜便产生了锁竞争,谁都向对小乔上锁.

如果两个线程对于两个不同的锁进行引用加锁,也就不会出现锁竞争问题:

java 复制代码
public class Demo12 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o1 = new Object();
        Object o2 = new Object();
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o1) {//o1对线程上锁
                    count++;
                }
            }
        });
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o2){//拿到的是o2锁,不会产生锁互斥
                    count++;//线程安全问题仍然存在
                }
            }
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(count);
    }
}

但是这样还是会产生线程安全问题.

运行结果:

讨论:join()和上锁的区别

join是在tread全部执行完成之后,再去执行tread1,而加锁是并发执行 .

在join等待的时候是WAITING状态,而在上锁过程中是BLOCKED状态.

  1. 可重入与不可重入(死锁)
    我们思考这样几个场景:
  • 场景一:一个线程一把锁
java 复制代码
public class Demo13 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o) {//对线程上锁
                    synchronized (o){//又上了一次锁
                        count++;
                    }
                }
            }
        });
        thread.start();
        thread.join();
        System.out.println(count);
    }
}

上面的代码tread两次利用o上锁,我们来思考,在第二次上锁的时候,会不会因为锁的互斥性,而使得tread线程产生阻塞,那就自己把自己锁死了,产生便了死锁.

举例说明:

有请助教:钟离

假如钟离在上厕所...

如果产生上述情况,我们称该锁为不可重入锁 .如c++,python中自带的锁,都是不可重入锁,一旦像上面那样写,就锁死了.

但是Java中的锁是可重入锁,对一个线程使用相同的锁进行多次加锁之后,不会出现锁死的情况.不会产生锁冲突.可见Java的创始者为了不让我们Java程序员写出bug,真的是操碎了心!!!

可重入锁的原理 :

在可重⼊锁的内部,包含了"线程持有者"和"计数器"两个信息.

• 如果某个线程加锁的时候,先判断这个线程是否被加锁,如果没有,则加锁,如果发现锁已经被⼈占⽤,但是恰好占⽤的正是⾃⼰,那么仍然可以继续获取到锁,并让计数器⾃增.

• 解锁的时候计数器递减为0的时候,才真正释放锁.(才能被别的线程获取到)

举例说明:

有请助教:小乔,周瑜,兰陵王


那么什么时候Java会产生死锁呢?

  • 场景二:两个线程两把锁
java 复制代码
public class Demo14 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Object o1 = new Object();
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o) {//1.拿到o锁
                    synchronized (o1){//3.与tread1的o1锁互斥
                        count++;
                    }
                }
            }
        });
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o1){//2.拿到o1锁
                    synchronized (o){//4.与tread的o锁互斥
                        count++;
                    }
                }
            }//3,4相互等待,最终卡死
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(count);
    }
}

运行结果:毛都没有!

这就说明,这里的的进程卡死,产生了死锁.

什么原理呢?由于tread一首先启动,tread拿到o锁,并上锁,此时tread1启动,拿到o1锁,当tread想要拿到o1锁的时候,发现o1锁被占用,阻塞等待,当tread1想要拿到o锁的时候,发现o锁被占用,阻塞等待,这时候tread1和tread相互循环相互等待,就产生了死锁.

这就像两个相互暗恋的人一样,都彼此暗恋着对方,但是都不敢鼓起勇气去表白,这样就会彼此错过.

  • 那么我们如何规避死锁呢?(重点面试题)
    首先我们要知道参数死锁的4个必要条件:
  1. 锁具有互斥性
  2. 锁不可剥夺
    上述是锁的两个基本的特性,我们无法干预
  3. 请求锁和保持锁
    一个线程拿到一把锁之后,不释放这个锁,就尝试获取其他锁.
  4. 循环等待:
    多个线程获取多个锁过程中,A等待B,B等待A.
    上述两个条件,我们都可以通过干预代码结构来解除死锁.
    我们需要约定好加锁顺序,让所有的线程按照一定的顺序加锁.
    我们尝试使用上面的方法对上面的场景二的死锁进行解除:调换lock1和lock2的位置,让tread执行完所有的逻辑之后释放锁之后,再轮到tread1执行.
java 复制代码
public class Demo14 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        Object o1 = new Object();
        Thread thread = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o) {
                    synchronized (o1){
                        count++;
                    }
                }
            }
        });
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (o){
                    synchronized (o1){
                        count++;
                    }
                }
            }
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(count);
    }
}
相关推荐
芒果披萨14 分钟前
El表达式和JSTL
java·el
Suckerbin1 小时前
Hms?: 1渗透测试
学习·安全·网络安全
duration~1 小时前
Maven随笔
java·maven
zmgst1 小时前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD1 小时前
前后端分离,Jackson,Long精度丢失
java
Diamond技术流1 小时前
从0开始学习Linux——网络配置
linux·运维·网络·学习·安全·centos
Spring_java_gg1 小时前
如何抵御 Linux 服务器黑客威胁和攻击
linux·服务器·网络·安全·web安全
blammmp1 小时前
Java:数据结构-枚举
java·开发语言·数据结构
暗黑起源喵2 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
WaaTong2 小时前
Java反射
java·开发语言·反射