【JavaEE】线程安全问题

简介: JavaEE & 线程安全问题重点重点重点~线程安全问题,就是某个代码在多线程环境下执行,会出bug,即线程不安全,即不符合预期~本质的本质,是因为线程之间的调度顺序是不确定的~

JavaEE & 线程安全问题

重点重点重点~


线程安全问题,就是某个代码在多线程环境下执行,会出bug,即线程不安全,即不符合预期~


本质的本质,是因为线程之间的调度顺序是不确定的~


1. 线程安全的一个经典例子

现在我们想要完成一个操作,就是将一个计数器count进行【++】

而这个操作,我们不仅仅在一个线程中去执行

而是在多个线程里去执行

这样子做会发生什么效果呢?

1.1 初步代码设计

首先我们可以将计数器做成对象,或者用“全局性质”的静态变量~

两个自己创建的线程和静态变量count

public class Test {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        thread1.start(); //启动
        thread2.start();
        thread1.join(); //等线程结束~
        thread2.join();
        System.out.println(count);
    }
}


效果是这样的~


2ba1cf89f3684766a36d3c9f2c474c40.gif

多次运行的结果好像不尽人意呀,如果5000-> 50000 ,现象会更明显~


da35708d16944c3f9db8df6b781e381e.gif

对,没错,答案大概率不是100000,总共加了100000次了,并且还是静态变量,为什么不对呢~

一个自己创造的线程和main线程 + 计数器对象

class Counter {
    private int count = 0;
    public void add() {
        count++;
    }
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }
        });
        for (int i = 0; i < 5000; i++) {
            counter.add();
        }
        thread1.start();
        thread1.join();
        System.out.println(counter.get());
    }
}


奇怪的是,为什么这两个线程反而结果是对的~


f971abea9752457e8c3284d744c62dca.gif

thread1.start();
System.out.println(counter.get());
thread1.join();
System.out.println(counter.get());

6bffeb8eaff34f35bacb124e1d33fd79.png



没错,因为只加5000次,main线程一下子就跑完了


你只要加多点,肯定会出错~


不过你较真的话,有百分之0.00000001的可能成功~


其实什么方式,只要次数低点,都很有可能结果正确


1.2 原因

count++语句的非原子性


“原子”在那个时期,是无法分割的意思

所以“原子性”代表【一条语句/一段语句】是不可分割的整体

对于一条语句


它可能分为多条汇编代码

对于一段语句


它本身就是可分割的

而在MySQL里的事务,是具有”原子性“的

1.2.1 count++ 的“非原子性”

90fd7bd6e2584941aa61302e65f8f8b0.png


count++在汇编中分为三个原子步骤

load,加载,即寄存器记录count值

add,增加值,即寄存器中的值进行增加操作

save,赋值,即将寄存器中值赋给内存中的count


75a24cdd5328453eb1556871fc2d1168.gif

1.2.2 线程的调度是无序的

count++分为3个原子步骤

而CPU的核心在执行的时候是按照原子步骤走的~

那么线程调度的时候,CPU的核心并不会一次性将count++这条语句执行完,而是并发或者并行的分次执行完~

并发

7e45661e50a641798e54ff62f28efc60.gif


并行

692c3dbd1f6e406ea9342d6d2f778237.gif


无论是哪一种,其根本原因,都是,在寄存器未把值赋给count的时候,此时的count就被其他寄存器记录了,导致,两次count++,实际只加了一次

要知道,别的线程再load的时候,是读内存的count值,如果此时count并没被赋值(save),就会浪费一次count++


fa51f0d3af3f4f2fb4b4613449878139.gif

当然,还是可能会出现极端情况的,并不代表最近结果是在【50000,100000】

而是【1,100000】(1就很极端极端极端了~)

因为可能多条count++,进行影响


f4d475648cf64885a67b3139b2c701f8.png

2. synchronized锁

在上面的经典案例中我们可以得知,线程的不安全的原因

抢占式执行(罪魁祸首)

多个线程修改同一变量

如果多个线程修改不同变量,是安全的

如果多个线程读取同一/不同变量,是安全的

一个线程修改同一变量,是安全的

而多个线程修改同一变量,是不安全的

那么怎么解决呢?


Java中有一个关键字synchronized,

这个关键字,可以让一个对象,在被一个线程修改(任何方面的变化)的时候,多个线程谁先“开始”修改,即抢到了“锁”,那么其他线程,就会进入阻塞等待的状态,“BOLOCKED”

即该修改语句以及其后的语句,均被设置为“原子”整体~

而该线程执行完后,释放锁,并且跟其他线程参与**“抢锁环节”**

2.1 代码演示 + 解析

class Counter {
    private int count = 0;
    public void add() {
        synchronized (this) {
            count++;
        }
    }
    public int get() {
        return count;
    }
}
public class Test1 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        for (int i = 0; i < 50000; i++) {
            counter.add();
        }
        thread1.start();
        thread1.join();
        System.out.println(counter.get());
    }
}


解决问题~


69bb5985286d4a37a52112042d248619.png

解释

526a9032bf5240c4990dbc8dda756654.png

342d92f2dbc643ed8771e16cf916e750.gif


这只是其中一种写法,其实还有以下几种写法~

28eee62fad914c65a9774beb3ab0312c.png


传对象

修改此对象时候,需要抢到锁才行

361c3260c2384b6e8dc5f345be470d98.png


用synchronized修饰方法

这个方法与锁(this)是一样的

整个方法为锁范围

c1cd6a05d045442196449ac4e69621bd.png


传类对象(.class)

这种方式适用于静态方法和普通方法

想修改此类的实例,就要抢到锁才行



用synchronized修饰静态方法

在整个方法为锁范围

跟传类对象是一样的

89570eec3b374def9a2f13d255fe312e.png


在对静态变量count进行count++操作的时候,count为基本数据类型,无法成为锁对象,就可以这么做~

Object类作为锁对象时,则代表,进入这个代码块,从始至终,必然需要抢到锁才行,没抢到锁的线程,阻塞等待~


不同于其他方式,这个并没有明确的锁对象

* 是个同步锁


其他方式,则是此对象仅仅可以被抢到锁的线程修改,执行完花括号的语句,才释放锁。

此过程中,此对象是绝对不能在其他线程被修改的,要修改必须阻塞等待,争取抢锁

注意:不同锁对象的话,就相当于修改不同变量,并不会引起阻塞,不会锁竞争,那就跟没加锁一样,反而增加了开销



补充说明:


类对象的含义:


类名

类的属性,属性名,类型,权限…

类的方法,方法名,参数列表(签名),返回类型,权限…

类继承哪些类

类实现哪些接口

类对象相当于“对象的图纸”


这个图纸反映了对象的“轮廓”

这样就可以用Java反射的API去做一些事情了~

当然,反射是非常规的语法

能不用,就不用~

忘了也无所谓~

类对象还能直接调用静态方法/静态属性


因为静态属性/静态方法,不依托于对象存在,类被加载即存在

所以,类对象就可以调用这些静态的东西了~

3. 内存可见性引发的线程不安全

什么是内存可见性问题呢?

先看一个bug

3.1 内存可见性bug例子

public class Test {
    public static int flag;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while(flag == 0) {
            }
            System.out.println("thread线程over");
        });
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = 1;
        });
        thread.start();
        thread1.start();
    }
}


我们想的是:flag在2毫秒后,被置为1,即非0,线程thread的循环停止,程序进而结束~

但是实际情况是:

e6472250c2914aceb513e65ad4053664.gif



2ms是可能让结果正确的,只是我这里没展示

而这是因为flag在线程thread循环判断前就被置为1了,那么就没法进入循环,紧接着程序就结束了~

1ms太短了,导致结果是正确的

3.2 线程不安全原因

7de81767fc294b38994a2e82f33a8468.png


内存可见性

本质上是编译器对代码的优化

为了增加程序运行效率

因为读内存比cmp操作慢太多了~

所以编译器做出了一个大胆的操作

既然每次循环都要用load

load的结果又都是一样的

那么我咋不把这个load省略掉

更详细点来说,就是不读主内存

main memory

只读工作内存:CPU寄存器

work memory


读寄存器/缓存 >> 读内存 >> 读硬件盘


cpu缓存:

6a65ba46ba9d4772b0900ec647cd140d.png


cpu查数据:


看看寄存器有没有

看看L1有没有

看看L2有没有

看看L3有没有

看看内存有没有…

这样子安排,是为了提高速度~


这一理论来自,Java官方的JMM(Java Memory Model)

所以此线程的flag的内存可见性,第一次读应用于以后~

因为此线程只知道,该线程内,并没有任何对flag有修改的意图

单线程,这个判断,很准确~

所以,编译器就误认为flag不会被更改-

并且把load给优化掉了

3.3 处理方式

我们需要编译器在遇到这种情况的时候,不要优化

让循环速度降低,加入sleep(xxx),这样load的优化就几乎没用,所以编译器就不会优化~

3274dc930cce425e890010d8d35951f3.png


使用volatile关键字~


用volatile修饰的变量,并不会被系统优化~

保证每次都是有load操作的,不会被省略~

42035ec33b144977bd91b0011ca7df84.png


volatile的用法很简单

volatile不保证“原子性”

volatile适用于一个线程写,一个线程读的情况~

防止代码都的时候被优化而已~

保证内存可见性~

synchronized则是适用于多个线程写,保证“原子性”

4. 指令重排序引起的线程不安全

这个也是编译器优化的策略~


即调整指令执行的顺序,让程序更加高效

同样的,这个行为只能保证此改动本线程内是无影响

对于其他线程的介入,不知道~

同样的,volatile关键字一样也能取消此优化~


1aef3c9dbdee4280b1f981e4ea46a5f1.png

如果外加一个线程,此线程判断a是否为null,如果不是,调用a的一个方法~

2cc0866653a54129a994c42b0ad8f403.png



如果,此时先执行第三步,则会导致错误!因为构造方法未被调用,可想而知有多严重

将相当于去买房子,装修再住,住了再装修,最终结果都一样~

在这里,我不给代码演示了,因为不好演示,并且错误率不高,但是也不能说没有~


4.1 处理方法

volatile取消优化

只需要用volatile去修饰a,就可以在创建引用的时候,禁止指令重排序

e5d0f4b083b7488ca53b736289e5a004.png


指令重排序其实也就是因为原子性没被保证

所以可以用synchronized去环绕代码块~

f957f09d14e2439d88dc9f9b2eb6e43e.png

相关文章
|
4月前
|
存储 安全 Java
【JavaEE】线程安全
【JavaEE】线程安全
|
14天前
|
消息中间件 监控 安全
【JAVAEE学习】探究Java中多线程的使用和重点及考点
【JAVAEE学习】探究Java中多线程的使用和重点及考点
|
24天前
|
监控 安全 Java
【JavaEE多线程】深入解析Java并发工具类与应用实践
【JavaEE多线程】深入解析Java并发工具类与应用实践
33 1
|
24天前
|
安全 Java API
JavaEE多线程】深入理解CAS操作:无锁编程的核心
JavaEE多线程】深入理解CAS操作:无锁编程的核心
20 0
|
24天前
|
算法 Java 编译器
【JavaEE多线程】掌握锁策略与预防死锁
【JavaEE多线程】掌握锁策略与预防死锁
24 2
|
24天前
|
设计模式 安全 Java
【JavaEE多线程】从单例模式到线程池的深入探索
【JavaEE多线程】从单例模式到线程池的深入探索
22 2
|
24天前
|
安全 Java 编译器
【JavaEE多线程】线程安全、锁机制及线程间通信
【JavaEE多线程】线程安全、锁机制及线程间通信
33 1
|
24天前
|
Java 程序员 调度
【JavaEE多线程】理解和管理线程生命周期
【JavaEE多线程】理解和管理线程生命周期
17 0
|
3月前
|
安全 Java 编译器
【JavaEE初阶】 线程安全的集合类
【JavaEE初阶】 线程安全的集合类
|
3月前
|
存储 缓存 安全
【JavaEE初阶】 线程池详解与实现
【JavaEE初阶】 线程池详解与实现