多线程初阶(二)

news/2024/11/28 5:25:48/

目录

前言:

synchronized

解析

可重入和不可重入问题

解析

Java中线程安全类

死锁问题

解析

解决死锁问题

解析

内存可见性

解析

volatile关键字

解析

wait,notify

解析

小结:


前言:

    针对上篇文章讲到的线程安全问题,我们需要保证一些指令的原子性,在代码中可以通过加锁实现。针对于加锁,这个是有一定的开销的,还有可能导致死锁问题。因此在加锁的时候要慎重考虑。

synchronized

    1)修饰普通方法是把锁加到当前引用对象上。

    2)修饰静态方法是把锁加到类对象上。

    3)修饰代码块,可以指定加到哪个对象上。

注意:

    如果两个线程针对同一个对象加锁,就会出现锁竞争/冲突,一个线程能够获得到锁,先到的线程先获得锁。另一个线程则需要阻塞等待,直到上一个线程解锁(方法执行完),这个线程就可以回到就绪队列,才能够获取到锁。

    这里加锁虽然在方法上修饰,但实际加锁都是加到对象上面的。只有两个线程针对同一个对象加锁,才会出现锁冲突。如果针对不同对象加锁,则都会获取到锁,不会产生阻塞等待。

class Cumsum {public int a = 0;synchronized public void add() {a++;}
}
public class ThreadDemo15 {public static void main(String[] args) {Cumsum cumsum = new Cumsum();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {for(int i = 0; i < 5000; i++) {cumsum.add();}}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {for(int i = 0; i < 5000; i++) {cumsum.add();}}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(cumsum.a);}
}

解析

    上篇讲述到这段代码,不加锁运行结果是有bug的,加了锁之后就正确了。不加锁出bug的原因在上片文章有讲述到,就是典型的线程安全问题。那么为什么加了锁之后代码就是正确呢?

    首先这里有两个线程t1和t2,main线程会阻塞等待,这两个线程并发执行。如果第一个线程首先获取到锁,这个锁是加到cumsum对象上的,当第二个线程在尝试获取到这个对象的锁,就会产生阻塞等待(对象是同一个)。直到上一个线程释放了这个对象的锁,这个线程才可以获取这个锁成功。获取锁成功后读取到a的值肯定是save过的,即就是正确的数值。

可重入和不可重入问题

    如果一个线程在一个方法里尝试针对同一个对象加锁两次,第一次加锁成功后,第二次在尝试对这个方法加锁,就会产生阻塞等待。即就会阻塞在这个方法里,第一次加的锁没有办法释放,程序就会一直阻塞在这里,产生死锁问题。

    针对给一个对象加锁两次产生的死锁问题,在java里很可能会写出这样的代码,因此synchronized就设计为可重入锁。对于这样死锁现象,可重入锁就不会产生阻塞等待,就会放过它,即代码可以正常执行。对于这样原因产生死锁问题,即就是不可重入锁。

class Add {public static int a = 0;synchronized public  void add() {a++;}synchronized public void add2() {synchronized (this) {a++;}}
}
public class ThreadDemo11 {public static void main(String[] args) throws InterruptedException {Add add2 = new Add();Thread thread1 = new Thread(new Runnable() {@Overridepublic void run() {for(int i = 0; i < 5000; i++) {add2.add();}}});Thread thread2 = new Thread(new Runnable() {@Overridepublic void run() {for(int i = 0; i < 5000; i++) {add2.add2();}}});//执行一次a++需要,load,add,save(都内存数据到寄存器,寄存器值++, 寄存器值写回内存)//由于两个线程是并发执行的,这些指令会随机组合(抢占式执行,随意调度),就会产生线程安全问题(不同的顺序,结果就会产生差异)//第二个线程读取的值是在第一个线程保存后读取的1,就会加2次(线程安全)//两次读取的值都为0,则最终只加1. 在一次线程切换中,另一个线程可能会执行多次三步流程(线程不安全)thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(Add.a);}
}

解析

    如果第一个线程先执行,会给add2对象加锁成功。这个时候第二个线程在尝试对于这个对象加锁就会产生阻塞等待。当第一个线程释放锁之后,第二个线程就会针对于这个对象加锁成功,执行代码块的时候又会针对这个对象加锁第二次,由于synchronized是可重入锁,即在这里不会产生死锁问题,即代码就会正常执行。

Java中线程安全类

    Vector  HashTable  ConcurrentHashMap  StringBuffer 这些集合类中都内置了synchronized锁,多线程中线程是安全的。String类由于不可修改性,即天然就是线程安全的。

    AyyayList  LinkedList  HashMap  TreeMap  HashSet  TreeSet  StringBuilder 这些集合类中在线程安全问题需要手动加锁。

死锁问题

    上面说的在一个线程里给一个对象加两次锁,如果锁是不可重入锁,那么就会产生死锁。如果线程1先获取到锁A,被调度走,线程2先获取到锁B,再尝试获取锁A,就会阻塞等待,线程1调度回来获取锁B,也会阻塞等待。这个时候两个线程都在等对方释放锁,程序就会卡着不动了,产生了死锁问题。多个线程多把锁,如果每个线程都获取到锁,并且都在等对方释放锁,那么每个线程都会卡着不动,产生死锁问题。

    死锁问题的核心就是循环等待,想要解决死锁问题,那么就需要打破这种循环等待。

public class ThreadDemo12 {public static void main(String[] args) {Object o1 = new Object();Object o2 = new Object();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (o1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (o2) {System.out.println("aaaa");}}}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (o2) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (o1) {System.out.println("bbbbb");}}}});t1.start();t2.start();}
}

解析

    线程1先个o1对象加锁,然后sleep(),线程2给o2加锁,然后sleep()。接下来线程1尝试获取o2的锁,线程2尝试获取o1的锁,即两个线程都会阻塞等待,产生死锁。

解决死锁问题

    给锁编号,约定获取锁的顺序,从小到大或者从大到小。任意线程在加锁的时候都遵循这样的规则,就可以打破循环等待的问题,那么死锁问题也就解决了。

public class ThreadDemo12 {public static void main(String[] args) {Object o1 = new Object();Object o2 = new Object();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (o1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (o2) {System.out.println("aaaa");}}}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (o1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (o2) {System.out.println("bbbbb");}}}});t1.start();t2.start();}
}

 解析

    线程1先获取o1再获取o2的锁,线程2也遵循这样的规则。调整了获取锁的顺序后,可以清楚看见代码正常执行了。

内存可见性

    如果针对于同一个变量即读又写,那么就会涉及内存可见性问题。实质上是编译器优化导致的bug。

    对于一个变量的修改,首先需要读取内存的数据到寄存器中,然后在寄存器中修改这个变量,最终写回到内存中。编译器优化可能会认为这个变量是不可变的,即在每次读数据的时候,只读取寄存器中的值,而不是修改后内存中的数据。这就导致读的数据就是修改之前的。

    一个线程针对于一个变量进行修改操作,同时另一个线程针对这个变量读取操作。此时读取到的值不一定是修改后的值,这个线程没有感知到这个变量的变化。

class Counter {//不能修饰局部变量//局部变量在不同的线程里占用不同的栈空间,意味这就是不同的变量(每个线程都有自己的栈空间)public int flag = 0;
}
public class ThreadDemo16 {public static void main(String[] args) {Counter counter = new Counter();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {while (counter.flag == 0) {}System.out.println("aaaa");}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {Scanner scanner = new Scanner(System.in);counter.flag = scanner.nextInt();}});t1.start();t2.start();}
}

解析

    当t2线程修改掉flag值为2时,t1线程在while中死循环。因为t1线程读取到的值是没有修改的flag。这就是编译器优化导致认为flag是不可变的,即每次都是读取寄存器中的值,而不是t2线程修改后内存中的值。 

volatile关键字

    解决内存可见性,使用volatile关键字。声明这个变量是可变的,即告诉编译器在每次读取数据时,需要读取内存中的数据,而不是寄存器中的数据。这个时候编译器就不会随便优化了。

class Counter {//不能修饰局部变量//局部变量在不同的线程里占用不同的栈空间,意味这就是不同的变量(每个线程都有自己的栈空间)volatile public int flag = 0;
}
public class ThreadDemo16 {public static void main(String[] args) {Counter counter = new Counter();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {while (counter.flag == 0) {}System.out.println("aaaa");}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {Scanner scanner = new Scanner(System.in);counter.flag = scanner.nextInt();}});t1.start();t2.start();}
}

 解析

    当给flag加上volatile关键字声明是可变的之后,while循环后的语句顺利打印了,说明t1线程读取到了t2线程修改后的值。因为voiatile修饰后,就会认为这个变量是可变的,即每次都会同步内存中的数据,即也就解决了内存可见性问题。

wait,notify

    由于线程的抢占式执行,随机调度。wait可以让线程主动放弃cpu的调度,进入阻塞队列。让其他线程可以被调度,可以控制线程的调度时机。当使用wait主动放弃cpu的时候,需要其他线程通过notify来唤醒该线程,进入就绪队列。wait和notify都是Object类下的方法。

    wait主动放弃cpu调度的机制,首先会释放锁,然后线程阻塞等待。那么在释放锁的时候需要有锁,即需要先得到锁然后在释放锁,进入阻塞队列。为什么这样设定呢?释放锁之后其他线程可以给这个对象加锁,就不会导致这个对象一直被加锁。wait不加任何参数就是死等。某个线程调用wait方法,就会进入阻塞队列(无论是哪个对象),此时就处在WAITING状态。

    notify通知线程唤醒机制。再唤醒线程也需要获得锁才可以唤醒线程。即首先需要获取锁,然后调用notify方法,唤醒线程进入就绪队列。notify只能唤醒同一个对象调用wait所阻塞的线程,如果有多个线程都在阻塞,则随机唤醒一个。notifyAll可以全部唤醒,一起进入就绪队列。这里的notify唤醒wait不会有任何异常。

public class ThreadDemo17 {public static void main(String[] args) throws InterruptedException {Object object = new Object();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("t1前");synchronized (object) {try {object.wait(); //不加任何参数就是死等} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("t1后");}});//notify只能唤醒同一个对象上的等待线程//如果和wait对象不一致,则不生效//多个线程wait的时候,notify随机唤醒一个,notifyAll全部唤醒,一起竞争锁Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("t2前");synchronized (object) {object.notify();}System.out.println("t2后");}});t1.start();Thread.sleep(500);t2.start();}
}

解析

    需要保证先启动t1线程通过synchronized先给object加上锁,然后通过wait方法释放锁,使该线程阻塞等待。当t2线程执行的时候,也是通过synchronized先给object加上锁,然后object对象调用notify方法通知t1线程,进入就绪队列。可以看见代码的执行顺序也是这样。这里wait和notify方法的调用对象需要一致,才能明确具体通知哪一个线程。

小结:

    与大家共勉歌德的名言:志向和热爱是伟大行为的双翼。 


http://www.ppmy.cn/news/1703.html

相关文章

【数据结构】二分搜索树

目录 一、二分搜索树 1.1什么是二分搜索树 1.2创建一个二分搜索树 &#xff08;1&#xff09;二分搜索树的内部构建 &#xff08;2&#xff09;插入操作 &#xff08;3&#xff09;判断一个val值是否存在 &#xff08;4&#xff09;按照节点的深度先序遍历打印BST &#…

【深度梯度投影网络:遥感图像】

Deep Gradient Projection Networks for Pan-sharpening &#xff08;用于全色锐化的深度梯度投影网络&#xff09; 全色锐化是遥感成像系统获取高分辨率多光谱图像的重要技术。最近&#xff0c;深度学习已经成为最流行的泛锐化工具。提出了一种基于模型的深度全色锐化方法。…

计算机毕业设计Java机票实时比价系统(源码+系统+mysql数据库+lw文档)

计算机毕业设计Java机票实时比价系统(源码系统mysql数据库lw文档) 计算机毕业设计Java机票实时比价系统(源码系统mysql数据库lw文档)本源码技术栈&#xff1a; 项目架构&#xff1a;B/S架构 开发语言&#xff1a;Java语言 开发软件&#xff1a;idea eclipse 前端技术&#…

Web大学生网页作业成品——抗击疫情网站设计与实现(HTML+CSS)

&#x1f389;精彩专栏推荐 &#x1f4ad;文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业&#xff1a; 【&#x1f4da;毕设项目精品实战案例 (10…

Spring(Bean 作用域和生命周期)

目录 1. 案例1: Bean作用域的问题 2. 作用域 3. 设置 Bean 的作用域 4. Spring 的执行流程 5. Bean 的生命周期 1. 案例1: Bean作用域的问题 现在有一个公共的 Bean,通过给 A 用户 和 B 用户使用, 然后在使用的过程中 A 偷偷的修改了公共 Bean 的数据, 导致 B 在使用时发…

MATLAB算法实战应用案例精讲-【图像处理】目标检测(补充篇)(附实战案例及代码实现)

前言 本文为MATLAB算法实战应用案例精讲-【图像处理】目标检测(附实战案例及代码实现)的补充篇。 目标检测从2001年开始,在2012年成为分水岭,因为这一年基于深度学习的目标检测方法,逐渐使目标检测进入到快速发展的阶段,比较流行的算法可以分为两类,一类是基于Region …

分析常见限流算法及手写三种(计数器、漏斗、令牌桶)代码实现

常见的限流算法分析 限流在我们日常生活中经常见到&#xff0c;如火车站门口的栏杆、一些景点的门票只出售一定的数量 等等。在我们的开发中也用到了这种思想。 为什么要限流 在保证可用的情况下尽可能多增加进入的人数,其余的人在排队等待,或者返回友好提示,保证里面的进行…

MySQL-MHA高可用配置及故障切换

文章目录一、MHA概述二、MHA的组成1、MHA Node&#xff08;数据节点&#xff09;2、MHA Manager&#xff08;管理节点&#xff09;3、MHA 的特点四、搭建步骤实验思路实验操作故障模拟故障切换备选主库的算法一、MHA概述 MHA&#xff08;MasterHigh Availability&#xff09;是…