阿里云开发者社区

电脑版
提示:原网页已由神马搜索转码, 内容由developer.aliyun.com提供.

Java多线程安全风险-Java多线程(2)

2024-05-2323
版权
版权声明:
本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《 阿里云开发者社区用户服务协议》和 《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写 侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。
简介:Java多线程安全风险-Java多线程(2)


观察多线程下的风险

class TestClass {    public int sum;    public void add(){        sum ++;    }}public class Main {    public static void main(String[] args) throws InterruptedException {        TestClass test = new TestClass();        Thread thread1 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        Thread thread2 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        thread1.start();        thread2.start();        thread1.join();        thread2.join();        System.out.println("the final result: " + test.sum);    }}

     我们使用两个线程, 分别对TestClass类下的test实例调用50000次add操作, 两个例子的操作都对test实例中的sum字段进行了50000次自增, 按理来说最后执行的结果应该是100000, 但是我们最终执行了3次, 分别得到了不同的结果, 如下:

发现: 预期结果是10w, 但是缺和实际上的不符, 三次运行的结果是个随机值, 结果都不确定, 实际结果和预期结果不一样, 这就是bug, 这也是多线程引起的bug之一,

三次的执行结果都不样, 这是为什么呢?

     其本质上是因为线程之间的调度是不确定的,

此处的sum++操作在本质上被大致分成了3个CPU指令:

  1. load 读取操作, 将内存中的数据读取到CPU寄存器当中
  2. add 自增操作, 将sum的值自增+1
  3. save 保存操作, 将寄存器中sum的自增结果保存到内存当中去

但是两个线程调度顺序时随机的. 不确定的, 实际上的sum++操作就有很多种指令排序的可能.

这里简单的举个例子, 如下:

这种情况, 两个线程按顺序调度, 那么就不会产生问题, 但是如果两个线程按照不规则顺序调度, 那么就会产生多线程问题:

经过上面的讨论, 我们对线程安全的概念做出一些总结

线程安全的概念

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

     百度百科中对线程安全的解释;

     程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

    

线程不安全的原因

修改共享数据

     上面我们讲到的sum++操作, 就是多个线程对同一个变量进行修改的例子,  此时的sum就是一个多线程能访问到的" 共享数据"

     此时可能就会有其他不相干的线程来修改这个数据, 此时就可能产生错误

原子性

     指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。

     在我们的代码当中, 每一个java语句不一定是原子的, 不一定只是一条指令, 就例如我们刚才所看到的n++操作, 实际上的大概是由三条指令构成:

  1. 读取内存的数据
  2. 修改数据
  3. 将结果数据保存到内存中去

如果不保证关键语句的原子性, 那么在多线程的情况下, 势必在操作一个变量的时候, 会有另外一个线程插入到其中, 来影响最终结果.

可见性

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

Java 内存模型(JMM): Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并 发效果.

  • 线程之间的共享变量存储在 主内存(Main Memory).
  • 每一个线程都有自己的"工作内存" (Working Memory) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的"副本". 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化

举个例子:

例如现在主内存中有一个数据: int a = 0;

     线程1 读取到了a = 0, 线程2 也读取到了a = 0, 但是线程1对a = 0进行了修改, 对a进行了自增操作, 也就是a = 1, 但是这个a = 1是存放在线程1的工作内存当中去, 并没有写入主内存, 这个时候线程2再对a进行相关操作就会出现bug

1) 为啥整这么多内存?

     实际并没有这么多 "内存". 这只是Java 规范中的一个术语, 是属于"抽象" 的叫法.

所谓的"主内存" 才是真正硬件角度的"内存". 而所谓的"工作内存", 则是指CPU 的寄存器和高速缓存.

2) 为啥要这么麻烦的拷来拷去?

     因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了3 - 4 个数量级, 也 就是几千倍, 上万倍).

既然访问寄存器速度这么快, 还要内存干啥??

答案就是一个字:

Java中线程安全的类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制.

  • Vector
  • HashTable
  • ConcurrentHashMap
  • StringBuffer

StringBuffer的核心方法都带有synchronized, 此外, 有些类虽然没有加锁, 但是被设定了无法修改, 仍然是线程安全的,例如 String类

     对于上述问题, 我们能否把这个sum++操作变成原子的呢? 这就要介绍java的锁机制了, 我们将sum++ 的多个指令集合捆绑在一起,让他能够在一次线程调度的时候全部执行, 这样子就解决这种线程随机调度所引起的问题,.

     锁, 可以保证java语句的原子性. 锁有两个核心操作:

  1. 加锁
  2. 解锁

一旦某个进程加了锁之后, 其他线程也想加锁, 就不能直接加上, 必须先阻塞等待, 知道拿到锁的线程释放了锁为止.

     由于其随机调度性, 如果有三个线程, 让线程1解锁之后, 线程2和线程3谁能拿到所是不确定的.

     java中如何进行加锁, 这就要谈到synchronized关键字

synchronized关键字(监视器锁-monitor lock)

     例如, 我们给上面的sum++进行加锁操作:

    public void add(){        synchronized (this) {            sum++;        }    }

     此处使用代买块的方式来表示: 进入synchronized修饰的代码块的时候就会触发加锁操作, 除了代码块就会触发解锁操作.

     其中的this为锁所指向的对象. 如果两个线程针对同一个对象加锁, 此时就会出现"锁竞争"(一file:///C:/Users/L/Desktop/JavaEE初阶/java107_0316_多线程.png个线程拿到了锁, 另外一个线程就需要阻塞等待).

     如果两个线程针对不同的对象进行加锁, 此时就不会存在锁竞争.

     这个里面的()里的对象, 可以是任意一个Object对象(除了内置类型), 此处写了this就相当于给test实例为锁对象:

     对于之前的例子, 我们对其进行加锁操作,并运行:

class TestClass {    public int sum;    public void add(){        synchronized (this) {            sum++;        }    }}public class Main {    public static void main(String[] args) throws InterruptedException {        TestClass test = new TestClass();        Thread thread1 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        Thread thread2 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        thread1.start();        thread2.start();        thread1.join();        thread2.join();        System.out.println("the final result: " + test.sum);    }}

此时才是我们想要的结果.

案例

例如这种情况, thread1已经先拿到了锁,  如果这个时候thread2再尝试进行加锁, 此时就会出现阻塞等待的情况, thread2就会等待thread1完成指令集并解锁. 这个本质上是把这个并发sum++操作变成了串行操作.

此外, 直接给方法加synchronized:

    synchronized public void add(){        sum++;    }

此时就相当于以this为所对象.  如果synchronized修饰静态方法, 此时就不是给this加锁, 而是给类对象加锁, 例如test.class.

特性:

file:///C:/Users/L/Desktop/JavaEE初阶/java107_0316_多线程.png互斥

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

    

  • 进入synchronized 修饰的代码块, 相当于 加锁
  • 退出synchronized 修饰的代码块, 相当于 解锁
        synchronized (this) {            sum++;        }

     synchronized用的锁是存在于java的对象里头的, 每个对象在存储的时候, 都有一块用来表示当前锁的状态的内存. 如果是无锁状态, 就可以对其进行加锁, 加锁后需要标识已经加了锁, 其他线程要使用, 如果发现已经加锁, 那么就只能阻塞等待.

     需要注意的是, 在阻塞等待后的线程, 不一定是先到的线程会先拿到锁, 这个是不确定的, 是由操作系统进行的随机调度.

    

刷新内存

synchronized的工作过程:

  1. 获取互斥锁
  2. 从内存中拷贝数据的副本到工作内存中去
  3. 执行代码
  4. 将执行结果返回存储到主内存当中去
  5. 释放锁

所以synchronized也能保证其内存的可见性

可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

什么是把自己锁死?

一个线程没有释放锁, 然后又重新再次加锁

// 第一次加锁, 加锁成功lock();// 第二次加锁, 锁已经被占用, 阻塞等待. lock();

按照之前所说的, 加了锁的线程, 再次加锁就会进入阻塞等待, 直到第一次的锁被释放, 但是释放锁的过程也是由这个线程来执行的, 这就产生了矛盾, 也就是无法进行解锁操作, 这个时候就被称之为"死锁"

案例:

     下面的代码中, doadd(), 和add(), 方法都加了synchronized修饰, 此处的synchronized都是针对当前this对象进行加锁的, 在调用add()方法的时候. 就已经加了一次锁, 执行到doadd()的时候又加了一次所, 但是上一个锁还没有释放.

     这个代码是完全没有问题的, 这就体现了synchronized是课重入锁的

class TestClass {    public int sum;    synchronized public void add(){        doadd();    }    synchronized public void doadd() {        sum++;    }}public class Main {    public static void main(String[] args) throws InterruptedException {        TestClass test = new TestClass();        Thread thread1 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        Thread thread2 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        thread1.start();        thread2.start();        thread1.join();        thread2.join();        System.out.println("the final result: " + test.sum);    }}

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

     如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增. 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

synchronized的使用

     synchronized本质上要修改指定对象的"锁标识", 所以在使用的角度来说也必须要搭配一个对象.

1. 直接修饰普通方法 : 锁的test对象

public class Test{    public synchronized void methond() {   }}

2.修饰静态方法: 锁的Test类的对象

public class Test{    public synchronized static void method() {   }}

3.修饰代码块: 明确指出锁哪个对象

public class Test {    public void method() {        synchronized (this) {               // 代码块       }   }}

volatile 关键字

先来看一个多线程bug:

public class Main {    public static int flag = 0;    public static void main(String[] args) throws InterruptedException {        Thread thread1 = new Thread(()-> {            while(flag == 0) {             }            System.out.println("Thread1结束!!");        });        Thread thread2 = new Thread(()->{            Scanner in = new Scanner(System.in);            System.out.println("输入一个整数");            flag = in.nextInt();        });         thread1.start();        thread2.start();    }}

上面的例子中, 使用全局变量flag作为线程1结束的标志判断, 然后再从线程2中去改变这个标志, 让线程1结束,, 但是我们在输入非0数字后, 线程并没有立马结束:

我们使用java的jdk.jconsole工具来查看这个线程1是否继续在运行:

可以发现这个Thread-0, 也就是我们的线程1, 并没有结束, 线程1 感受不到线程2对flag进行的修改,

这就是内存可见性的问题,

但是如果给flag + 上 volatile:

public static volatile int flag = 0;
public class Main {    public static volatile int flag = 0;    public static void main(String[] args) throws InterruptedException {        Thread thread1 = new Thread(()-> {            while(flag == 0) {             }            System.out.println("Thread1结束!!");        });        Thread thread2 = new Thread(()->{            Scanner in = new Scanner(System.in);            System.out.println("输入一个整数");            flag = in.nextInt();        });         thread1.start();        thread2.start();    }}

为什么会产生上面这种问题?

我们来看这个while循环

load操作从内存读取数据到寄存器, 然后进行compare操作, 此处的cmp操作, load操作的时间开销是远远超过cmp的.

但是此时的编译器就发现, load的开销很大, 同时每次load的结果都是一样, 于是编译器就把这个load操作给又花掉了, 这样子就只执行了第一次load, 后续就只进行cmp操作.

volatile不保证原子性

     volatile 和synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

class TestClass {    volatile public int sum;        public void add() {        sum++;    }}public class Main {    public static void main(String[] args) throws InterruptedException {        TestClass test = new TestClass();        Thread thread1 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        Thread thread2 = new Thread(()-> {            for (int i = 0; i < 50000; i++) {                test.add();            }        });        thread1.start();        thread2.start();        thread1.join();        thread2.join();        System.out.println("the final result: " + test.sum);    }}

volatile不能保证其原子性

synchronized也能保证内存的可见性:

import java.util.Scanner; class TestClass {    public int sum;     public void add() {        sum++;    }}public class Main {    public static void main(String[] args) throws InterruptedException {        TestClass test = new TestClass();        Thread thread1 = new Thread(()-> {            while(true) {                synchronized (test) {                    if (test.sum != 0) {                        break;                    }                }            }        });        Thread thread2 = new Thread(()-> {            Scanner in = new Scanner(System.in);            test.sum = in.nextInt();        });        thread1.start();        thread2.start();        thread1.join();        thread2.join();        System.out.println("the final result: " + test.sum);    }}

wait和notify

     由于线程之间都是抢占式执行, 各线程之间的执行顺序难以预知, 但是实际开发中我们有时候需要合理的协调多个线程之间的执行先后顺序.

     而程序之间的协调调度主要涉及到三种 Object类的方法

1.wait() / wait(Long time) 让线程进入等待状态

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

wait()

wait所执行的流程:

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

注意: wait要搭配synchronized来使用, 脱离synchronized使用wait会直接抛出异常

wait 结束等待的条件

  • 其他线程调用该对象的notify 方法.
  • wait等待时间超时, wait方法有一个指定参数的方法, 来制定等待时间
  • 其他线程调用该线程的interrupted方法, 导致wait抛出异常

一个案例

import java.util.Scanner; public class Main {    public static void main(String[] args) throws InterruptedException {        Object object = new Object();        System.out.println("wait之前!");        object.wait();        System.out.println("wait之后 !!");    }}

运行发现抛出异常(无效锁状态异常):

我们需要配合synchronized使用:

import java.util.Scanner; public class Main {    public static void main(String[] args) throws InterruptedException {        Object object = new Object();        System.out.println("wait之前!");        synchronized (object) {            object.wait();        }        System.out.println("wait之后 !!");    }}

结果才是正确的:

加锁的对象必须和wait的对象是同一个, 同时notify也要放在synchronized中使用.

但是我们也不能让他一直这样等待下去, 我们应该在需要唤醒他的时候来唤醒它.

wait(Long time)

wait还有一个传入参数版本的, 可以指定等待的时候, 如果时间过了就自动结束等待:

import java.util.Scanner; public class Main {    public static void main(String[] args) throws InterruptedException {        Object locker = new Object();        Thread thread1 = new Thread(()->{            while (true) {                System.out.println("wait start!");                synchronized (locker) {                    try {                        locker.wait(3000);                    } catch (InterruptedException e) {                        throw new RuntimeException(e);                    }                }                System.out.println("wait ended !");            }        });        thread1.start();     }}

notify() / notifyAll()

这个时候就要用到notify了

notify()执行的流程:

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

一个简单的案例:

import java.util.Scanner; public class Main {    public static void main(String[] args) throws InterruptedException {        Object locker = new Object();        Thread thread1 = new Thread(()->{            while (true) {                System.out.println("wait start!");                synchronized (locker) {                    try {                        locker.wait();                    } catch (InterruptedException e) {                        throw new RuntimeException(e);                    }                }                System.out.println("wait ended !");            }        });        thread1.start();         Thread.sleep(1000);        // 必须要先wait, 才能notify才有效果, 如果还没有wait就notify, 此时wait就唤不醒,但是不会出现异常        Thread thread2 = new Thread(()->{           synchronized (locker) {               System.out.println("this is notify start!!");               locker.notify();               System.out.println("this is notify ended !");           }        });        thread2.start();    }}

运行结果:

注意: 如果此时有三个线程thread1, thread2, thread3中都调用了object.wait, 此时如果在main方法中调用一个object.notify(), 会随机唤醒这三个线程中的一个, 另外两个仍然是wait状态, 如果调用了object.notifyAll, 此时就会把三个线程都唤醒. 然后这三个线程就会同时竞争锁,然后随机调度.

wait和sleep的对比

wait带有一个有时间参数版本的, 可以自动唤醒,  这个时候就感觉和sleep差不多.

但是他们最大的区别在于根本的用法, 或者是说设计这个东西是用来干嘛的, 是不一样的.

  • wait是解决线程之间的控制顺序, 而sleep是单纯的让线程休眠一会!
  • 实现上也是有区别的: wait需要搭配锁来使用, 必须拿到锁之后才能wait, 而sleep不需要


目录
相关文章
|
1天前
|
Java开发者
Java中的多线程基础与应用
【9月更文挑战第22天】在Java的世界中,多线程是一块基石,它支撑着现代并发编程的大厦。本文将深入浅出地介绍Java中多线程的基本概念、创建方法以及常见的应用场景,帮助读者理解并掌握这一核心技术。
|
2天前
|
Java程序员
Java中的多线程基础与实践
【9月更文挑战第21天】本文旨在引导读者深入理解Java多线程的核心概念,通过生动的比喻和实例,揭示线程创建、同步机制以及常见并发工具类的使用。文章将带领读者从理论到实践,逐步掌握如何在Java中高效地运用多线程技术。
|
2天前
|
Java数据处理
Java中的多线程编程:从基础到实践
本文旨在深入探讨Java中的多线程编程,涵盖其基本概念、创建方法、同步机制及实际应用。通过对多线程基础知识的介绍和具体示例的演示,希望帮助读者更好地理解和应用Java多线程编程,提高程序的效率和性能。
1511
|
10天前
|
存储缓存安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
4天前
|
缓存Java应用服务中间件
Java虚拟线程探究与性能解析
本文主要介绍了阿里云在Java-虚拟-线程任务中的新进展和技术细节。
|
20天前
|
监控Java调度
【Java学习】多线程&JUC万字超详解
本文详细介绍了多线程的概念和三种实现方式,还有一些常见的成员方法,CPU的调动方式,多线程的生命周期,还有线程安全问题,锁和死锁的概念,以及等待唤醒机制,阻塞队列,多线程的六种状态,线程池等
【Java学习】多线程&JUC万字超详解
|
3天前
|
Java
领略Lock接口的风采,通过实战演练,让你迅速掌握这门高深武艺,成为Java多线程领域的武林盟主
领略Lock接口的风采,通过实战演练,让你迅速掌握这门高深武艺,成为Java多线程领域的武林盟主
1677
|
6天前
|
Java
深入理解Java中的多线程编程
本文将探讨Java多线程编程的核心概念和技术,包括线程的创建与管理、同步机制以及并发工具类的应用。我们将通过实例分析,帮助读者更好地理解和应用Java多线程编程,提高程序的性能和响应能力。
|
14天前
|
Java调度开发者
Java并发编程:深入理解线程池
在Java的世界中,线程池是提升应用性能、实现高效并发处理的关键工具。本文将深入浅出地介绍线程池的核心概念、工作原理以及如何在实际应用中有效利用线程池来优化资源管理和任务调度。通过本文的学习,读者能够掌握线程池的基本使用技巧,并理解其背后的设计哲学。
|
5天前
|
安全Java调度
Java 并发编程中的线程安全和性能优化
本文将深入探讨Java并发编程中的关键概念,包括线程安全、同步机制以及性能优化。我们将从基础入手,逐步解析高级技术,并通过实例展示如何在实际开发中应用这些知识。阅读完本文后,读者将对如何在多线程环境中编写高效且安全的Java代码有一个全面的了解。

热门文章

最新文章