JavaEE之多线程的风险以及如何避免

上文我们了解了单线程以及线程的一些基本常见方法,但是多线程在一些方面会存在安全问题,此文我们来为多线程的安全 保驾护航!! 详情请见下文

1. 多线程带来的风险------线程安全

1.1 观察线程不安全

java 复制代码
/**
 * 使用两个线程,让count自增到10w
 */
public class Thread_lesson03_01 {
    //定义一个变量,让其自增10w次
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        thread1.start();
        
        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        thread2.start();
        
        //等待线程结束
        thread1.join();;
        thread2.join();
        
        //结束后,打印计算的count值
        System.out.println("count="+count);
    }
}

1.2 线程安全的概念

想给出⼀个线程安全的确切定义是复杂的,

但我们可以这样认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的

1.3 线程不安全的原因

1. 线程调度是随机的

由于线程的执行顺序无法人为控制,抢占式执行是造成线程安全问题的主要原因而且我们解决不了,完全是CPU自己调度而且和CPU核数有关

2. 修改共享数据

  • 多个线程修改同一个变量,会出现线程安全问题
  • 多个线程修改不同的变量,不会出现线程安全问题
  • 一个线程修改一个变量,也不会出现线程安全问题

3. 原子性

我们在前面的MySQL学习过程中,知道了事务是有原子性的,比如对一个事务,如果不让他完全执行任务时,就对他操作,会造成幻读,不可读等等不好的效果,MySQL中我们通过隔离级别保证事务的原子性,原子性即 :事务要么全都执行,要么全都不执行

在线程中,我们知道⼀条Java语句不⼀定是原⼦的,也不⼀定只是⼀条指令

比如刚才我们看到的count++,其实是由三步操作组成的:

  1. 从内存把数据读到CPU (LOAD)
  2. 进行数据更新 (ADD)
  3. 把数据写回到CPU (STORE)

由于线程是抢占式执行的,此处通过时间线来助于理解线程的原子性:

下图,对线程的原子性助以理解:

由于执行CPU指令不是原子性的,导致这三条指令没有全部执行完成就被CPU调度走了
另外的线程加载到的值是一个原始值,当两个线程分别完成自增操作之后把值写回内存时发生了覆盖现象

4. 内存可见性

可见性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.

Java虚拟机规范中定义了Java内存模型(JMM) ,如下图所示:

  1. 线程之间的共享变量存在主内存(MainMemory).
  2. 每⼀个线程都有自己的"⼯作内存"(WorkingMemory),且线程工作内存之间是隔离的,线程对共享变量的修改线程执行相互感知不到
  3. 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷贝到⼯作内存,再从⼯作内存读取数据.
  4. 当线程要修改⼀个共享变量的时候,也会先修改⼯作内存中的副本,再同步回主内存.
  5. 工作内存是JAVA层面对物理层面的关于程序所使用到了寄存器的抽象
  6. 如果通过某种方式 让线程之间可以相互通信,称之为内存可见性

5.指令重排序

指令重排序概念:

重排序是编译器、JVM和CPU为了提高执行效率对指令顺序进行调整的现象。它在保证单线程语义不变的前提下,减少了读写操作,提升了程序运行速度,并且保证程序的运行结果是正确。重排序分为编译器优化、CPU重排序和内存系统的"重排序"。虽然带来性能提升,但也要注意其可能影响多线程环境中的数据一致性。

2. 如何解决线程不安全问题

1.线程的调度是随机执行的:硬件层面的,我们解决不了

2.修改共享数据:在真实业务场景中,很难避免多线程,提供效率,我们解决不了

3.原子性:我们可以通过🔒锁实现原子性,下文介绍

4.内存可见性:我们可以让进程之间通过一种通信关系,解决内存的不可见性

5.指令重排序:我们可以程序员直接指定那个先执行

能够解决 3、4、5 其中一项,我们的线程不安全问题就可以解决

3.synchronized 关键字

3.1 synchronized 的特性

synchronized 会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待.

  • 进⼊synchronized修饰的代码块,相当于加锁
  • 退出synchronized修饰的代码块,相当于解锁

synchronized⽤的锁是存在Java对象头⾥的。

可以粗略理解成,每个对象在内存中存储的时候,都存有⼀块内存表示当前的"锁定"状态(类似于厕所 的"有⼈/⽆⼈").

  • 如果当前是"⽆⼈"状态,那么就可以使⽤,使⽤时需要设为"有⼈"状态.
  • 如果当前是"有⼈"状态,那么其他⼈无法使用,只能排队


理解"阻塞等待"

针对每⼀把锁,操作系统内部都维护了⼀个等待队列.当这个锁被某个线程占有的时候,其他线程尝试进⾏加锁,就加不上了,就会阻塞等待,⼀直等到之前的线程解锁之后,由操作系统唤醒⼀个新的线程, 再来获取到这个锁.
注意:

  • 上⼀个线程解锁之后,下⼀个线程并不是⽴即就能获取到锁.⽽是要靠操作系统来"唤醒".这也就 是操作系统线程调度的⼀部分⼯作.
  • 假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C 都在阻塞队列中排队等待.但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁, ⽽是和C重新竞争,并不遵守先来后到的规则.

3.2 synchronize关键字的魔力

我们通过下列代码来探究synchronize的魔力吧!


3.2.1 代码1:不添加synchronize关键字,查看计算结果

java 复制代码
public class Thread_lesson04_01 {
    public static void main(String[] args) throws InterruptedException {
        //初始化累加对象
        Counter01 counter01=new Counter01();

        //创建两个线程对一个变量进时累加
        Thread thread1=new Thread(()->{
            counter01.increase();
        });

        Thread thread2=new Thread(()->{
            counter01.increase();
        });

        // 启动线程
        thread1.start();
        thread2.start();

        //等待线程
        thread1.join();
        thread2.join();
        System.out.println(counter01.count);
    }
}
class Counter01 {
    public int count=0;
    public void increase(){
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    }
}

3.2.2 代码2:给increase方法加上synchronize关键字

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

此时,thread1线程先获取了锁,方法执行完成之后thread2线程再获取锁,这样的情况是一个单线程运行状态,是把多线程转换为一个单线程,从而解决线程安全问题,但并不是我们想要的效果


3.2.3 代码3:给increase方法中的代码块加上synchronize关键字

java 复制代码
public class Thread_lesson04_03 {
    public static void main(String[] args) throws InterruptedException {
        Counter03 counter=new Counter03();
        Thread thread1=new Thread(()->{
            counter.increase();
        });
        Thread thread2=new Thread(()->{
            counter.increase();
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("count="+counter.count);
    }
}
class Counter03 {
    public int count=0;
    public  void increase(){
        //在真实业务中,在执行锁代码块之前有很多的数据获取或其他的可以并行执行的逻辑
        //1.从数据库中查询数据 selectAll()
        //2.对数据进行处理 build()
        //3.其他的不修改共享变量的方法
        //......
        
        //当执行到修改共享变量的逻辑时,再加锁
        //被锁修饰的代码块用{}包裹,其中()中可以是任何对象,使用this就是当前调用该方法的对象
        synchronized(this) {for (int i = 0; i < 50000; i++) {
            count++;
        }
        }
    }
}

虽然当前代码依旧是按串行执行了,但是在锁定的代码块前后,有其他的方法或代码可以进程执行


3.2.4 代码4:定义方法increase和方法increase1,increase方法用synchronize关键字修饰,线程1调用increase方法,线程2调用increase1方法

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

    public void increase1(){
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    }
}

由于increase1方法不加锁,在对increase1方法方法执行时,increase1方法不需要锁即可执行,导致了线程不安全问题,具体情况如下:


总结 :关于synchronized
1.被 synchronized 修饰的代码会变成串行执行
2.synchronized 可以去修饰方法,也可以修饰代码块
3.被synchronized 修饰的代码并不是一次性在CPU上执行完,而是中途可能会被CPU调度走,但是只有当所有的指令执行完成之后才会释放锁
4.只给一个线程加锁,也会出现线程安全问题

了解完了synchronize关键字后,我们来谈谈何为锁,锁又有哪些知识呢??

4. 锁🔒

我们知道,事务的隔离级别是通过锁和MVCC机制保证的,那么Java当中锁是用什么来实现的呢?锁存放在哪里呢??

4.1 锁是如何解决线程安全问题的?

  1. 解决线程非原子性

通过synchronize给方法加锁解决了原子性问题

  1. 解决内存不可见性

后一个线程永远读到的是上一个线程存放到主内存的值,通过这样的方式实现了内存可见性,
并没有对内存可见性做技术上的处理

  1. 解决不了重排序问题

8.2 锁存放的位置

在Java虚拟机中,对象在内存中的结构可以划分为4个区域

  1. markword: 对象头:锁信息、GC(垃圾回收)次数,程序计数器,一般为8BYTE
  2. 类型指计:当时的对象是哪个类,一般为4BYTE
  3. 实例数据:成员变量,不定
  4. 对齐填充:一个对象所的占的内存必须是8byte的整数倍,根据实例数据确定

5. volatile 关键字

上面我们了解到引发线程不安全的问题有几种情况:

  • 线程的调度是随机的
  • 多个线程修改了共享数据
  • 原子性
  • 内存不可见性
  • 指令重排序

其中线程的调度是随机的,我们解决不了,对于共享数据的修改,我们使用了synchronize关键字对修改共享数据的代码进行加锁,实现了原子性,由于原子性让代码串行执行,间接的实现了内存的可见性,然而在真正意义上并没有对内存的不可见性做修改,Java中提供了volatile关键字,实现了内存可见性和禁止指令重排序,我们现在开始来揭开volatile神秘的面纱吧

volatile的作用: volatile 修饰的变量, 能够保证 "内存可见性",也可以解决有序性的问题(禁止指令重排序)

5.1 内存可见性

代码在写入 volatile 修饰的变量的时候

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

前⾯我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),

速度非常快, 但是可能出现数据不⼀致的情况.

加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.

我们提供一段代码示例,来揭开volatile的魅力吧

在这个代码中

  • 创建两个线程 thread1 和 thread2
  • thread1中包含⼀个循环, 这个循环以 flag != 0 为循环条件.
  • thread2中从键盘读⼊⼀个非0整数, 并把这个整数赋值给 flag.
  • 预期当用户输入 !=0 的值的时候, thread1 线程结束.
java 复制代码
public class Thread_lesson05_01 {
    static int flag=0;
    public static void main(String[] args) {
        Thread thread1= new Thread(()->{
        System.out.println("thread1线程启动...");
            while (flag==0) {
                //do nothing
            }
            System.out.println("thread1线程退出...");
        });

        //想要通过线程2对flag的值进行修改,让线程1停止
        Thread thread2= new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("thread2线程启动...");
            System.out.println("输⼊⼀个非0整数:");
            flag=scanner.nextInt();
        });

        //启动线程
        thread1.start();
        thread2.start();

    }
}

为何thread2对flag值的修改thread1无法感知到呢??

thread1 读的是自己的工作内存(寄存器)中的内容.

当 thread2 对 flag 变量进行修改, 此时 thread1 感知不到 flag 的变化.

图解:

下面我们试着给flag加上volatile关键字修饰,查看效果:

java 复制代码
static volatile int flag=0;

我们发现通过volatile关键字解决了内存不可见问题,那么volatile是究竟如何解决的呢?

我们知道为了解决内存的可见性问题,最重要的就是当一个线程修改了另一个线程需要的变量,必须让另一个线程感知到

在CPU层面:

在Java层面:

内存屏障
作用是保证指令执行的先后顺序从而保证内存可见性

volatile读:

volatile写:


5.2 有序性

用volatile 修饰过的变量,由于前后有内存屏障,保证了指令的执行顺序,也可以理解为告诉编译器不要进行指令重排,以此保证了有序性

5.3 volatile不能保证原子性

我们通过一段代码来测试volatile是否可以保证线程的原子性

java 复制代码
public class Thread_lesson05_02 {
    static class Counter01{
       volatile int  count=0;
        void increase(){
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //初始化自增对象
        Counter01 counter01=new Counter01();
        
        //创建两个线程对一个变量进时累加
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter01.increase();
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter01.increase();
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        //等待线程
        thread1.join();
        thread2.join();
        System.out.println("count="+counter01.count);
    }
}

结论 :volatile解决不了原子性问题

6. wait 和 notify

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序,所以Java中的Object类给出了wait和notify方法

此时我们就有一个疑问了,前面我们不是学习过join方法也可以进行等待呀,为什么Java还要提供wait方法呢?

我们可以通过一个案列来理解:

一家人要吃包子 (主线程) ,妈妈让小明去买包子 (子线程),此时join主要等待子线程的结果,即一家人等着小明买回来的包子,当小明来到包子铺,有两种情况:

  1. 包子已经做好,小明可以直接买回家
  2. 老板正在做包子,此时小明就需要等待包子做好
    此时小明买包子这个线程和老板包包子这个线程没有主子线程之分,但是要等老板做包子这个线程的结果,这是就需要用wait(),当老板做好包子之后,大喊一声包子出锅了notify()

总结: join()用于主子线程的等待,wait()用于非主子线程的等待,wait()可以理解为当前线程等待另一个线程准备资源,当资源准备好之后,通过notify()通知当前线程

6.1 wait()方法

wait()/wait(long timeout): 让当前线程进入等待状态

wait 做的事情:

  • 使当前执行代码的线程进行等待.(把线程放到等待队列中)
  • 释放当前的锁
  • 满足⼀定条件时被唤醒,重新尝试获取这个锁.

wait要搭配synchronized来使用.脱离synchronized使用wait会直接抛出异常.

wait 结束等待的条件:

  • 其他线程调用该对象的notify方法.
  • wait等待时间超时(wait方法提供⼀个带有timeout参数的版本,来指定等待时间).
  • 其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常.

6.2 notify()方法

notify() 唤醒在当前对象上等待的线程.

  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其
    它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程。(并没有"先来后到")
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

6.3 notifyAll方法

notifyAll(): 唤醒在当前对象上等待的线程.

notify方法只是唤醒某⼀个等待线程.使⽤notifyAll⽅法可以⼀次唤醒所有的等待线程.

6.4 演示wait() 和 notify()方法的使用

java 复制代码
import java.util.concurrent.TimeUnit;

/**
 * 演示wait() 和 notify()方法的使用
 * 创建两个线程,一个线程调用wait(),一个线程调用notify()
 */
public class Thread_lesson05_03 {
    public static void main(String[] args) {
        //定义一个锁对象
        Object locker=new Object();
        
        //创建调用wait()线程
        Thread t1=new Thread(()->{
                synchronized (locker) {
                    System.out.println("调用wait()之前...");
                    // 执行线程的逻辑
                    // 如果没有满足线程所需要的数据,那么就等待
                    try {
                        locker.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("wait()唤醒之后..");
                }
        });


        Thread t2 = new Thread(() -> {
                System.out.println("notify()之前...");
                // 同等待的锁对象进行唤醒
                synchronized (locker) {
                    locker.notify();
                }
                System.out.println("notify()之后...");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
        });

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


wati与notify 必须配置synchronized一起使用, 并且使用同一个锁对象

注意:

  1. 当一个线程调用了wait之后,就释放掉当前持有的锁,等待被其他的线程唤醒
  2. 当另一个线程调用了notify之后,之前调用了wait的线程被唤醒,需要重新去竞争锁,拿到锁之后,会从wait的位置继续执行逻辑

使用小结:

  1. wait和notify必须搭配synchronized一起使用
  2. wait和notify使用的锁对象必须是同一个
  3. notify执行多少次都没有关系(即使没有线程在wait)

6.5 wait和sleep的区别

其实理论上wait和sleep完全是没有可比性的,因为

  1. ⼀个是用于线程之间的通信的,⼀个是让线程阻塞⼀段时间
  2. 唯⼀的相同点就是都可以让线程放弃执行一段时间
  3. wait需要搭配synchronized使用,sleep,join不需要
  4. wait是Object的方法,sleep是Thread的静态方法
相关推荐
天使day12 小时前
SpringMVC
java·spring·java-ee
寻找沙漠的人14 小时前
理解JVM
java·jvm·java-ee
寻找沙漠的人14 小时前
JavaEE 导读与环境配置
java·spring boot·java-ee
Theodore_10221 天前
3 需求分析
java·开发语言·算法·java-ee·软件工程·需求分析·需求
神的孩子都在歌唱1 天前
Java 和 J2EE 有什么不同?
java·开发语言·java-ee
程序员大金2 天前
基于SSM+Vue的个性化旅游推荐系统
前端·vue.js·mysql·java-ee·tomcat·mybatis·旅游
Ttang232 天前
Tomcat原理(4)——尝试手动Servlet的实现
java·开发语言·servlet·java-ee·tomcat·intellij-idea
daiyang123...2 天前
JavaEE 【知识改变命运】06 多线程进阶(1)
java·java-ee