多线程-线程安全

目录

线程安全问题

加锁(synchronized)

synchronized 使用方法

synchronized的其他使用方法

synchronized 重要特性(可重入的)

死锁的问题

对 2> 提出问题

对 3> 提出问题

 解决死锁

对 2> 进行解答

对4> 进行解答

volatile 关键字

wait 和 notify (重要)

wait使用实例:

notify使用实例:

" 线程饿死 "

notify 和 notifyAll

小结


线程安全问题

线程安全问题: 有些代码在单个线程环境下执行完全正确. 但是如果同样的代码让多个线程同时执行, 此时就可能出现 bug, 这种情况叫做 "线程安全问题" / "线程不安全", 它是多线程中最复杂, 最重要的部分

举个例子: 两个线程, 每个线程count++ 5000次, 正常情况下结果为 10w, 实际结果:如下图:

public class Test {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {int i = 0;while(i < 5000) {count++;i++;}});Thread t2 = new Thread(() -> {int i = 0;while(i < 5000) {count++;i++;}});t1.start();t2.start();//如果没有这俩 join , 肯定不行, 线程还没有自增完毕, 就开始打印了,//打印出来的count 可能是 0;t1.join();t2.join();//预期结果是 10wSystem.out.println(count);}
}

改变一下 join 的次序可以让结果输出正确, 如下代码:

public class Test {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {int i = 0;while(i < 5000) {count++;i++;}});Thread t2 = new Thread(() -> {int i = 0;while(i < 5000) {count++;i++;}});t1.start();t1.join();t2.start();t2.join();//预期结果是 10wSystem.out.println(count);}
}

这个代码意味着 t1 执行时 t2并不会启动, 虽然上述代码卸载两个线程中, 但并不是同时执行的, 而第一个代码中 t1 和 t2 同时执行了, 第二个代码结果输出正确, 为 10w, 我们可以猜测是因为两个线程同时执行的原因导致第一个结果出错了.

解释:

count++ 这个操作本质上是分三步进行的 ~~ 站在 cpu 的角度, 是 cpu 通过三个指令实现的

1> load 把数据从内存读到 CPU 寄存器中

2> add 把寄存器中的数据进行 +1

3> sava 把寄存器中的数据保存到内存中

由于多个线程执行上述代码, 由于线程之间的调度顺序是 "随机" 的, 就会导致有些调度顺序下, 上述的逻辑就会出现问题.

如图:

这只是其中的一种情况, 还可能有无数种情况, 这三个步骤的排列顺序有很多种了, 还有可能 t1 连续执行了多次, 然后 t2 再次执行的情况, 有无数种排列顺序.

我们意识到在多线程程序中最困难的一点是: 现成的随机调度, 是两个线程执行逻辑的先后顺序存在很多可能, 我们要做的是保证在每一种情况下都输出正确的结果.

举个例子看一下: 不同的情况怎么输出结果的:

理想情况下:

两次相加后, 最终可以输出2

可能会出现的情况:

这种情况下两次相加得到的结果为 1

因为线程调度是随机的, 很容易出现错误情况, 这样的话最终的结果是一个随机值, 随机值小于 10w.

产生线程安全的原因:

1> 操作系统中, 线程的调度顺序是随机的 (抢占式执行)

2> 两个线程针对同一个变量进行修改

3> 修改操作不是原子的

此处给定的 count++ 就属于是非原子的操作, 先读取, 在修改, 有三个指令

4> 内存可见性问题

5> 指令重排序问题

 如何解决这个问题呢? 从这些原因入手

1> 调度随机性在系统内核里实现的, 最早的操作系统奠定了这个基调, 无能为力.

2> 有些情况可以通过调整代码结构来规避在这个问题, 有些情况规避不了

3> 有办法让 count++ 三步走成为 "原子" 的   ---->  (加锁) 的方法

加锁(synchronized)

synchronized 使用方法

需要搭配一个代码块 {   } 使用, 进入  {  就会加锁, 出去  }  就会解锁

作用

在已经加锁的状态下, 另一个线程尝试同样加这个锁, 就会产生 "锁冲突/锁竞争", 后一个线程就会阻塞等待, 一直等到前一个线程解锁为止.

使用方法举例

用上述代码进行举例:

count++ 加在代码块中, 然后 synchronized()  这个后面的 () 需要表示一个用来加锁的对象, 这个对象是啥不重要, 重要的是通过这个对象来区分两个线程是否在竞争同一个锁, 如果两个线程是针对同一个对象加锁, 就会有锁竞争, 反之不会有锁竞争, 仍然是并发执行.

追妹子: 你想妹子表白, 成功了就相当于加锁了, 另一个小哥准备追同一个妹子, 就得阻塞等待, 等你俩分手了他才有机会, 如果他准备追另一个对象, 那么可以直接表白.

public class Test {public static int count = 0;public static void main(String[] args) throws InterruptedException {//我们任意定义一把锁Object locker = new Object();Thread t1 = new Thread(() -> {int i = 0;while(i < 5000) {//进行加锁synchronized (locker) {  count++;i++;}}});Thread t2 = new Thread(() -> {int i = 0;while(i < 5000) {//进行加锁synchronized (locker) {count++;i++;}}});t1.start();t2.start();t1.join();t2.join();//预期结果是 10wSystem.out.println(count);}
}

 运行结果正确了.

加过锁之后两个线程相互影响, 在进行 count++ 时会先加锁, t1 线程加过锁了, t1没执行完之前, t2 操作会出现阻塞. 只有当 t1 中 count++ 的操作执行完之后才会让 t2 中的 count++ 进行操作, 这就避免了 t1 中的 load add save 与 t2 中的 load add save 操作 出行穿插, 此时线程安全问题就迎刃而解了.

如果在 两个线程加锁时 使用不同的 锁 那么就不会出现锁竞争, 上述问题就不会解决

其中synchronized 后面 () 中的锁对象到底是哪个对象无所谓, 重要的是俩线程加锁的对象是否是同一个对象.

synchronized的其他使用方法

synchronized 还可以修饰 一个方法

class Counter {public int count;synchronized public void increase() {count++;}public void increase2() {synchronized (this) {count++;}}
}
public class Test {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.increase();}});t1.start();t2.start();t1.join();t2.join();//预期结果是 10wSystem.out.println(counter.count);}
}

两种的写法是一样的, 上面是下面的简化版本

synchronized还可以修饰一个 静态方法

两种的写法是一样的, 上面代码是下面代码的简化版本

其中 Counter.class 是类对象

.java => .class => JVA加载到内存中(类对象) 可以看作是 .Java 文件中的二进制码

类对象中包含以下内容: 

1> 类的属性有哪些, 名字, 类型, 权限

2> 类的方法有哪些, 名字, 类型, 权限

3> 类本身继承自哪个类, 实现了哪些接口

在一个 Java 进程中, 类对象是唯一的.

synchronized 用的锁是存在 Java 对象头里的

Java 的一个对象, 对应的内存空间中, 除了你自己定义的一些属性之外, 还有一些自带的属性, 在对象头中, 其中就有属性表示当前的对象是否已经加锁.

synchronized 重要特性(可重入的)

可重入定义: 一个线程连续针对一把锁加锁两次, 不会出现死锁, 满足这个要求就是 "可重入" , 不满足就是 "不可重入" .

 死锁的解释: 有一个线程 t , 锁对象 locker,  t 线程中存在下列代码:

synchronized (locker) {

          synchronized (locker) {

                 ........... 

         }

}

第一次加锁能够加锁成功, 此时 locker 属于 "被锁定" 的状态, 第二次加锁 locker 已经是锁定状态, 第二次加锁操作, 应该要 "阻塞等待" 的, 等到锁被释放之后才能加锁成功

第二次想要加锁成功, 需要第一次加锁释放锁, 释放锁就要第二次加锁成功

这样就出现了死锁现象. 就是一个bug, 可能会出现这种情况

    private static Object locker = new Object();public static void func1() {synchronized (locker) {func2();}}public static void func2() {func3();}public static void func3() {func1();}public static void func4() {synchronized (locker) {}}

 这种bug时常出现而且不容易发现.

问题是: 上述代码中, synchronized 是可重入锁, 没有因为第二次加锁而死锁, 加入上述加锁过程有 N 层, 释放时机该如何判定? 

解答: 此处无论有多少层锁, 都是到在最外层才能释放锁, 提前释放会线程不安全

引用计数: 锁对象中不但要记录谁拿到了锁, 还要记录锁被加了几次, 每加锁一次, 计数器 +1, 每解锁一次, 计数器 -1, 除了最后一个大括号恰好减成 0 , 才真正释放锁.

死锁的问题

关于死锁总结: 

1> 一个线程针对一把锁, 连续加锁两次, 如果是不可重入锁, 就死锁了. (synchronized 不会出现)

2> 两个线程, 两把锁 (此时无论是不是可重入锁, 都会死锁)

 t1  t2 两个线程,  A 和 B 两把锁

t1 获取锁 A, t2 获取锁 B,  t1 尝试获取B, t2 尝试获取 A. 这种情况出现死锁.

3> N 个线程, M 把锁 (相当于 2> 的扩充) 更容易出现死锁的情况了, 经典模型: 哲学家就餐问题.

对 2> 提出问题

public class Test {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (locker1) {// 此处的 sleep 很重要, 要确保 t1 和 t2 都分别拿到一把锁// 之后再进行动作try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t1 加锁成功!");}}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1) {System.out.println("t2 加锁成功!");}}});t1.start();t2.start();}
}

此时的代码会出现死锁情况, 什么都没法打印出来

 此时打开 jconsole (java带的一个查看线程情况的工具) 就可以看到这俩线程的状态:

当前线程出现了阻塞状态;

对 3> 提出问题

死锁是属于很严重的 bug (导致线程卡住, 无法执行后续工作)

死锁的成因涉及到四个 必要条件

1> 互斥使用 (锁的基本特性) 当一个线程持有一把锁之后, 另一个线程也想获取到锁, 就要阻塞等待

2> 不可抢占 (锁的基本特性) 当锁已经被线程 1 得到之后, 线程2 只能等线程1 主动释放出来, 不能强行抢过来

3> 请求保持 (代码结构) 一个线程尝试获取多把锁, (先拿到锁1 之后, 在尝试获取锁2 的时候锁1 不会释放, 就是的上面的2> 例子

4> 循环等待/环路等待  (代码结构) 等待的依赖关系形成环了, 上面的3> 哲学家就餐的例子

 解决死锁

如何解决/避免死锁呢?

核心是破坏上述必要条件

1> 和 2> 是锁的特性, 不能改变, 要从 3> 和 4> 着手

3> 来说, 调整代码结构, 避免编写 "锁嵌套" 逻辑,  当然这个方案不一定好使, 有的需求可能就是需要获取多个锁之后再操作

4> 通过约定加锁顺序, 就可以避免循环的等待

对 2> 进行解答

调整代码结构, 避免编写 "锁嵌套" 逻辑

public class Test {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (locker1) {// 此处的 sleep 很重要, 要确保 t1 和 t2 都分别拿到一把锁// 之后再进行动作try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}synchronized (locker2) {System.out.println("t1 加锁成功!");}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}synchronized (locker1) {System.out.println("t2 加锁成功!");}});t1.start();t2.start();}
}

这样就能正常输出结果了.

对4> 进行解答

通过约定加锁顺序, 就可以避免循环的等待

针对锁进行编号, 比如约定 加多把锁的时候先加编号小的锁, 后加编号大的锁.(所有线程都遵守这个规则) 举个例子如下图:

最终死锁问题迎刃而解, 本质上是破除了循环等待

synchronized 使用规则并不复杂, 抓住一个原则, 两个线程针对同一个对象加锁, 就会产生锁竞争.

volatile 关键字

作用: 

1> 保证内存可见性

2> 禁止指令重排序

1>什么是 内存可见性

计算机运行的程序/代码, 经常要访问数据, 这些依赖的数据往往存在在 内存中, (定义一个变量, 变量就是存在内存中), CPU 使用这个变量的时候, 就会把这个内存中的数据先读出来, 放到 CPU 的寄存器中在参与运算 (load)

CPU 读内存 相当于 读硬盘 快几千上万倍, 读寄存器 相比于 读内存 又快了几千上万倍, 为了提高效率, 编译器把代码做出优化, 把一些本来要读内存的操作, 优化成读其寄存器, 减少读内存的次数, 也就可以提高整体程序的效率了

举个例子:

public class Test {private static int isQuit = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {while(isQuit == 0) {}System.out.println("t1 退出");});t1.start();Thread t2 = new Thread(() -> {System.out.println("请输入 isQuit: ");Scanner sc = new Scanner(System.in);isQuit = sc.nextInt();});t2.start();}
}

当我们输入 isQuti == 1 或者其他不为零的数时程序应该停止运行, 但结果不是. 打开 jconsole 可以看到 t1 线程正在执行, 为 RUNNABLE 状态

之前是两个线程修改用一个变量会引起线程安全问题, 现在是一个线程读, 一个线程修改也有可能会有问题, 就是因为 内存可见性引起的

 

使用方法:

在 isQuit 变量加上 volatile限制就可以了

关于内存可见性还涉及到一个关键概念, JMM(Java Memory Model, Java 内存模型) Java规范文档的叫法.

JMM 把存储空间分为 主内存 和工作内存, t1线程对应 isQuit 变量, 本身是在 主内存中的, 由于此处的优化就会把 isQuit 变量放到工作内存中. 进一步的 t2 修改主内存的 isQuit, 不会影响到 t1 的工作内存. 主内存就是咱们平常说的内存, 工作内存就是 CPU 寄存器.

volatile 可以保证内存可见性, 但是不能保证原子性

wait 和 notify (重要)

wait 和 notify 都是 Object 方法, 随便定义一个对象, 都可以使用 wait notify .它俩需要配合使用

作用: 用来协调多个线程的执行顺序

本身多个线程的执行顺序是随机的 (系统随即调度, 抢占式执行) 很多时候希望通过一定手段, 协调执行顺序. join 使用像线程结束的先后顺序, 相比之下此处是希望线程不结束, 也能够有先后顺序的控制.

wait 等待:  让指定线程进入阻塞状态

notify 通知:  唤醒对应的阻塞状态的线程

wait如何使用

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

此处代码报错, 非法的 监视器 状态 异常 , 其中 synchronized 就是监视器锁. 

wait 操作在执行的时候要做 三件事

1> 释放当前的锁

2> 让线程进入阻塞

3> 线程被唤醒的时候重新获取到锁

通过object 调用wait 释放锁的过程. 释放锁的前提就是 先加锁

public class Test {public static void main(String[] args) throws InterruptedException {Object object = new Object();synchronized (object) {System.out.println("wait 之前");//将 wait 放到 synchronized 里面调用, 保证确实是拿到了锁object.wait();System.out.println("wait 之后");}}
}

应该把 wait 写到 synchronized 里面加锁, 此时可以运行代码, 但是wait 会持续阻塞等待下去, 直到其他线程调用 notify 唤醒. 

此处的状态就是 waiting 状态

wait 除了默认的无参版本之外, 还有一个带参数的版本, 带参数的版本就是指定一个时间

参数的版本就是指定超时时间, 避免 wait 无休止地等待下去.

notify如何使用

public class Test {public static void main(String[] args){Object object = new Object();Thread t1 = new Thread(() -> {synchronized (object) {System.out.println("wait 之前");try {object.wait();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("wait 之后");}});Thread t2 = new Thread(() -> {try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (object) {System.out.println("进行通知");object.notify();}});t1.start();t2.start();}
}

 

结果如图.

" 线程饿死 "

针对这种情况:可以使用 wait 和 notify 来解决

让 1 号老铁在发现没钱的时候就进行 wait.(wait 内部本身就会释放锁, 并且进入阻塞)

让 1 号老铁不再进行后续的锁竞争, 把所释放出来让别人获取. 给其他老铁提供机会

运钞车把钱运过来的线程就是调用 notify 唤醒的线程

notify 和 notifyAll

notify 一次唤醒一个线程

notifyAll 一次唤醒全部线程

调用 wait 不一定只有一个线程调用, N 个线程都可以调用 wait, 此时有多个线程调用的时候这些线程都会进入阻塞状态., 唤醒的时候就有两种方式了

nitifyAll: 唤醒的时候 wait 涉及到一个重新获取锁的过程, 需要串行执行(用的更少)

notify: 更可控(用的更多)

小结

保证线程安全需要 : 保证原子性, 可见性, 顺序性:

了解synchronized 和 wait notify , volatile 的语法, 目的

掌握死锁的几种情况, 及如何解决死锁问题.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/705441.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何管理测试计划?测试计划管理都使用哪些在线工具?YesDev

3.2 测试计划 测试计划Testing plan&#xff0c;描述了要进行的测试活动的范围、方法、资源和进度的文档&#xff1b;是对整个信息系统应用软件组装测试和确认测试。 3.2.1 管理测试计划 在测试计划&#xff0c;可以查看、管理和维护全部测试计划。 测试计划列表 点击【测…

Python送你小花花

快到520了&#xff0c;准备好送上你的爱意了吗&#xff1f; 还记得去年从网上模仿了一篇python使用turtle画的小花花程序&#xff0c;当时还没有转行到程序员行业&#xff0c;刚刚入门学习编程&#xff0c;还在纠结是学习python、Java还是C#的时候。 总会被一些猎奇的内容吸引&…

怎么做私域?先来了解私域运营模式!

现在&#xff0c;很多企业都在做私域&#xff0c;但仍旧有很多人会问&#xff1a;我的私域到底要怎么做&#xff1f; 关于这个问题&#xff0c;不同产品无论在消费频次与客单价上&#xff0c;还是在决策链路的长度和复杂度上&#xff0c;都有巨大的差异&#xff0c;消费者需要…

docker-java 操作docker

部署docker 10分钟学会Docker的安装和使用_docker安装-CSDN博客文章浏览阅读2.5w次&#xff0c;点赞44次&#xff0c;收藏279次。文章目录Docker简介Docker安装Windows安装Linux安装CentOS安装Ubuntu安装最近花了些时间学习docker技术相关&#xff0c;在此做一些总结&#xff0…

Spring Security实现用户认证二:前后端分离时自定义返回Json内容

Spring Security实现用户认证二&#xff1a;前后端分离时自定义返回Json内容 1 前后端分离2 准备工作依赖WebSecurityConfig配置类 2 自定义登录页面2.1 Spring Security的默认登录页面2.2 自定义配置formLogin 3 自定义登录成功处理器4 自定义登录失败处理器5 自定义登出处理器…

org.springframework.jdbc.BadSqlGrammarException

Cause: java.sql.SQLSyntaxErrorException: Table ‘web.emp’ doesn’t exist 产生原因&#xff1a;web表找不到&#xff0c;所以可能数据库配置错误 spring.datasource.urljdbc:mysql://localhost:3306/web02 更改完成后运行成功

slugify,slug格式转换工具

目录 前言 安装 特性 基本功能 生成简单的Slug 处理特殊字符 Unicode支持 高级功能 自定义替换规则 过滤停用词 使用不同的分隔符 处理多种语言 实际应用场景 网站和博客的SEO优化 电子商务平台的产品链接 数据清洗和预处理 总结 前言 在Web开发中&#xff0c;生成易于…

【Linux】进程间通信(IPC)

在Linux操作系统中&#xff0c;进程间通信&#xff08;Inter-Process Communication, IPC&#xff09;是多任务编程中的核心概念之一。它允许不同进程之间共享数据、传递消息和同步执行&#xff0c;是构建复杂应用程序的基础。本文将深入浅出地介绍Linux下的几种主要IPC机制&am…

这个notebook集合,赞

这几天在Github上看到一个数据科学仓库&#xff0c;汇总了很多Python notebook代码&#xff0c;主要是数据方向。 项目地址&#xff1a; https://github.com/donnemartin/data-science-ipython-notebooks 其中包括了pandas、numpy、matplotlib、scikit-learn、tensorflow、sp…

提升用户体验:Xinstall免邀请码功能详解

在移动互联网时代&#xff0c;App的推广和运营显得尤为重要。然而&#xff0c;传统的App推广方式往往需要用户填写繁琐的邀请码&#xff0c;这不仅降低了用户体验&#xff0c;还影响了推广效果。幸运的是&#xff0c;Xinstall作为国内专业的App全渠道统计服务商&#xff0c;推出…

线上网页点击菜单没有反应 报错ChunkLoadError:Loading chunk chunk-***** failed

现象 点击菜单无反应并且控制台报错Loading chunk chunk-***** failed 具体错误现象截图如下 分析 在线上页面已经打开的情况下&#xff0c;重新打包部署了前端项目。每次打包&#xff0c;js文件的hash值都会发生改变&#xff0c;因为我们的路由采用了懒加载&#xff0c;未…

Android Studio kotlin 转 Java

一. 随笔记录 java代码可以转化成kotlin代码&#xff0c;当然 Kotlin 反过来也可以转java 在Android Studio中 可以很方便的操作 AS 环境&#xff1a;Android Studio Iguana | 2023.2.1 二. 操作步骤 1.步骤 顶部Tools ----->Kotlin ------>Show Kotlin Bytecode 步…