《JavaEE篇》--多线程(2)

news2024/11/15 15:30:23

《JavaEE篇》--多线程(1)

线程安全

线程不安全

我们先来观察一个线程不安全的案例:

public class Demo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        Thread t2 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        t1.start();
        t2.start();
        //如果没有join,线程还没执行完,就会打印count
        t1.join();
        t2.join();
        //预期结果应该是十万次
        System.out.println("count: " + count);
    }
}

两个线程同时对一个变量进行修改,按理来说最后的输出结果应该是十万,现在我们运行一下

可以以看到似乎并不像我们预期的那样,每个结果都不一样,而且有些结果和我们预期的相差很大。

上述代码如果放在单线程里肯定是对的,但是如果放在多线程里就会出现逻辑错误了

先要知道怎么回事我们要先了解一下count++这个操作。

Count++这个操作实际上,是分成三步进行的,站在cpu的角度上,count++是由cpu通过三条指令来实现的

  1. load 把数据从内存读到cpu寄存器上
  2. add  把寄存器中的数据+1
  3. save 把寄存器中的数据,保存到内存中

 由于线程之间的调度顺序是随机的,就会导致在有些调度顺序下,就会出现上述逻辑错误,接下来我通过画图的方式详细讲解。

上述两种执行顺序是正常的执行顺序(t1线程执行完整个count++动作,之后t2线程再执行,或者t2先执行),但是由于线程的随机调度,可能当t1执行load和add的操作之后,这时t2插了进来完成自己的操作。就像这样

那么此时会产生什么结果呢?

可以看见此时编译器执行了两次count++操作但是实际的count只被加了一次,这还只是一种顺序,实际编译时两个操作执行count++又会有多少种呢?如果t1执行一次,t2执行两次,三次呢又会有多种?必然是我们无法知晓的。

结合上述讨论,就意识到了,在多线程中最困难的一点就是:线程的随机调度,使两个线程执行逻辑的先后顺序存在诸多可能,我们要保证在所有可能的情况下,代码都是正确的。

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

 线程不安全的原因

线程安全产生的原因主要有以下五点

  1. 操作系统中,线程的调度是随机的(抢占式执行)//主要原因
  2. 两个或多个线程对同一个变量进行修改
  3. 修改操作不是原子性的(比如count++就是非原子操作,需要多条指令才能完成操作)
  4. 内存可见性问题(之后讲到)
  5. 指令重排序(之后讲到)

 原子性:原子性不一定只是一条java语句或者只是一条cpu指令,如果一个操作在执行的时候不会有其他操作干扰或插入,就可以说这个操作是具有原子性的,比如如果把刚刚的count++加上一把锁让编译器,必须执行完一个count++后才能执行下一个count++,这样不管t1,t2线程的执行顺序如何,都不会影响最后结果。

保证原子性这一点也和线程的抢占式调度密切相关,如果线程不是抢占式(随机)的,就算没有原子性也没有什么关系

那么我们如何保证线程是安全的?我们先暂时针对前三个问题进行解决,先看第一个,这是操作系统的特性我们不好对其进行修改, 那么再看后两个,如果我们保证操作是原子性的,这样不管对变量怎么修改,就都不会出错了。那么怎么保证原子性呢?最好的方法就是加锁

加锁

我们先来解决刚刚的代码

 private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                //当同时对一个对象加锁时,必须要等前一个代码块执行完,后一个代码块才能执行,就可以解决问题了
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        //如果没有join,线程还没执行完,就会打印count
        t1.join();
        t2.join();
        //预期结果应该是十万次
        System.out.println("count: " + count);
    }

运行结果:

可以看见现在的结果正是我们预期的

synchronized

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待,换言之,当两个synchronized同时对一个对象加锁(什么样对象一般无所谓只要是相同的就行),必须要等前一个synchronized代码块执行完,后一个代码块才能执行。

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

举个例子🌰:

如果A对B表白,并且B同意了此时就相当于A对对象B进行了加锁,此时C就不能再追求B了,只能等A和B分手后(解锁),B又回归单身后,此时C就可以光明正大的追求B了。

//synchronized用的锁是存在Java对象头里的,加锁就相当于对对象头进行了修改

可以粗略的理解成对象再内存中储存的时候,都有一块内存表示对象是否被锁,如果被锁则其他线程不能再次加锁,如果没被锁则可以加锁。

其他写法

这两种方法等价

 //这两种方法等价
    synchronized public void add1(){
        this.count++;
    }
    public void add2(){
        synchronized (this){
            count++;
        }
    }
//这两种方法等价
    synchronized public static void add3(){

    }
    public static void add4(){
        synchronized (Count.class){

        }
    }

这两种方法也等价,上面的写法相当于下面的简化写法 

Count.class叫做类对象,类对象在java进程中是唯一的,一般包含以下内容

  1. 类的属性有哪些,都是啥名字,类型,权限
  2. 类的方法有哪些,都是啥名字,类型,权限
  3. 类本身继承自哪个类,实现了什么接口

 可重入

  • 不可重入锁:只判断这个锁有没有被锁上,如果没被锁上就可以对其加锁,如果被锁上就要等待它解锁之后才可以加锁
  • 可重入锁:不仅会判断这个锁有没有被锁上,还会判断这个锁是被谁锁上的,如果是被当前线程锁上的则可以连续加锁,并且不出现死锁

简单来说一个线程,连续针对一个一把锁,加锁两次,不会出现死锁,满足这个要求,就是可重入

不可重入锁

当一个代码块被锁上,此时如果在代码块内部再加上一把锁就会出现死锁,如下

如果synchronized是不可重复锁,针对上述代码,在第一次加锁时可以成功,此时locker已近是被加锁的状态了,当进行第二次加锁时,因为locker已经被加锁了所以第二把锁就会阻塞等待,要等到第一把锁解锁之后才能加成功。但是实际上,一旦第二把锁阻塞等待了,就会出现死锁,因为第一把锁要想解锁就要执行到(1)的位置,要想执行到(1)的位置就要让第二把锁成功加锁,由于第二次加锁导致代码阻塞了,就没办法执行到(1)的位置了也就没办法释放锁了。

//死锁的发生不一定是在一个方法,也可能会发生在方法调用之间

 可重入锁

可重入锁是一种支持可重入机制的锁,当对一把锁连续加锁两次第二把锁不会出现阻塞(换言之,外层使用锁之后,内层任然可以使用),允许一个线程反复获得该锁,避免了死锁的发生,同时也提高了代码的简洁性的可读性,我们刚刚讲到的synchronized就是可重入锁

我们在对这个案例进行分析,因为synchronized是可重入锁,所以没有因为第二次加锁而死锁,但是当代码执行到(2)时,此时,锁(1)应不应该打开?又或者说,这里有N把锁,那么释放的时机应该如何?

为了解决这个问题,可重入锁设计了加锁次数,每加一次锁就加1,释放一次锁就减1,当计数为0时才真正释放锁,以此来保证所有的加锁过程都解锁了,其他线程才能访问。

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

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

关于死锁

1.一个线程针对一把锁,连续加锁两次,如果是不可重入锁,就会发生死锁

2.两个线程,两把锁,t1线程先获取锁A在获取锁B,t2线程先获取锁B在获取锁A(此时无论是不是可重入锁都会死锁),就相当于一个人把车钥匙锁在家里,把家里钥匙锁在车里

 3.N个线程M把锁

关于N个线程M把锁有一个经典的模型--哲学家就餐问题

哲学家就餐问题

规则是这样的:

  • 每个哲学家会做两件事1.思考人生,放下筷子啥也不干  2.吃面条,拿起左右两侧的筷子,开始吃面条
  • 哲学家什么时候吃面条,什么时候思考人生是随机的
  • 哲学家什么时候吃完面条,什么时候思考完人生也是随机的
  • 哲学家正在吃面条时会拿起左右两则的筷子,如果相邻的哲学家也想吃面条就需要阻塞等待

 基于上述规则通常情况下,是可以正常运行的,但是当处在极端情况下时,比如五位哲学家同时想吃面,又同时拿起左边的筷子,这时就会出现死锁的情况(这里哲学家就相当于一个线程,筷子就相当于是锁,拿起一个筷子相当于解开了一把锁)。

处理方法:

  1. 对哲学家进行限制,当哲学家确认左右两边的筷子都可以用时才会拿起筷子吃面(确认之后其他人不会再拿起),使拿起筷子这个操作成为一个原子性的,就可以避免死锁发生。
  2. 找到一位右撇子的哲学家(先拿起右边的筷子,其他人都是左撇子),目的是让这个哲学家拿筷子的顺序和其它人不一样,当其他哲学家都拿起左边的筷子时,此时这个哲学家右边就没有筷子了,就处于阻塞等待,此时就会有一个哲学家成功拿到两双筷子。
  3. 限制吃饭的人数,方法很简单,就是在一个时间点最多只能有4个哲学家进餐,这样4个让分5双筷子,一定能有人吃的到,就不会发生死锁。
  4. 规定一个哲学家只有在两个相邻的哲学家都不吃面的情况下,才可以吃面,这样就彻底解决了死锁问题,和第一种方法有点类似。

Volatile

内存可见性

线程之间的共享变量存在主内存(实际物理内存)中,而且每一个线程还有自己的工作内存(寄存器/cpu高速缓存),当线程要读取一个共享变量时,会把变量从主内存拷到工作内存中,再从工作内存读取数据,当线程要修改一个共享变量时,也会先修改工作内存中的副本,在同步到主内存中。这样可能就会导致线程1修改了共享变量a,但是主内存和线程2中数据更改不及时。

内存可见性会带来的问题

我们先来看段代码

正常来将当我们输入1时t1线程就结束了

但是实际上无论我们如何输入1,t1都没有结束,这是怎么回事?

计算机运行的程序,经常要访问数据,这些数据往往会存储在内存中(比如,定义一个变量,变量就会存储在内存中),当cpu使用这个变量的时候,就会把这个变量,先从内存中读出来,再放到cpu的寄存器中,最后在参与运算。cpu读取内存的操作是比较缓慢的,相对而言读寄存器就快的多了。

为了及解决上述问题,提高效率,此时编译器就可能对代码作出一些优化,把一些本来要读内存的操作,转化为读寄存器,减少读内存的次数,也就提高了程序的效率了

当编译器在处理isQuit == 0时会涉及到俩条指令1.load读取内存中的isQuit的值到寄存器中,2.通过cmp指令比较寄存器里的值是不是0,然后决定要不要循环。

由于循环执行的速度非常快,短时间内就会完成大量load和cmp操作。这时编译器/JVM就会发现,虽然进行了这么多次load,但是每次load的结果都一样,并且load操作非常消耗时间,比cmp操作消耗的时间多得多,于是编译器就做了一个大胆的就决定,只在第一次循环操作的时候,才读内存,后续都不在读内存了,而是直接从寄存器中取出isQuit的值,这个就是编译器的优化。

编译器是希望代码能够更高效,才在保证代码逻辑不变的情况下,对代码进行优化,但是,此时我们是由另外一个线程对isQuiti进行了修改,编译器没有做出正确的判断,以为没人修改,最终导致了bug,这个问题就是内存可见性问题。

这时我们可以利用Volatile来解决这个问题

在多线程的环境下,编译器有时会做出一些不准确的优化,此时就需要我们程序猿自己手动来矫正。我们可以通过Volatile关键字,来告诉编译器不要进行优化。我们直接在刚刚的代码上操作,直接在isQuit前加上Volatile就行。

运行结果

可以看到这时程序成功执行

wait和notify

wait():让当前线程等待阻塞

wait的工作过程是,释放当前的锁,使当前的线程进行等待,满足唤醒条件后,重新尝试获得这把锁(wait必须搭配synchronized使用,换言之当前线程必须得到锁,否则直接使用的化会抛出异常)

wait可以传参,表示最多等待的时间(ms)

notify():唤醒wait等待的线程

notify工作过程是,唤醒wait,然后解锁

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

notifyAll():可以一次唤醒所有的等待线程.

使用:

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

wait和sleep的区别

不同点:

  • wait 需要搭配 synchronized 使用. sleep 不需要.
  • wait 是 Object 的方法 sleep 是 Thread 的静态方法.
  • wait是为了让线程稍作等待,避免线程盲目争夺资源,sleep这时单纯让线程休眠一段是时间
  • wait是用于线程之间的通信,sleep只是用于让线程阻塞一段时间
  • 线程在wait时会释放锁,在sleep时不会
  • wait线程处于WAITING状态,sleep时线程处于TIMED_WAITING状态
  • wait被notify提前唤醒不会报出异常,sleep被提前唤醒会报异常
  • 相同点:
  • 都会让线程阻塞一段时间
  • 都可以设置最大等待时间时间

以上就是博主对线程知识的分享,在之后的博客中会陆续分享有关线程的其他知识,如果有不懂的或者有其他见解的欢迎在下方评论或者私信博主,也希望多多支持博主之后和博客!!🥰🥰

下一篇博客博主将分享有关单例模式等知识,还希望多多支持一下!!!😊

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

zabbix“专家坐诊”第247期问答

问题一 Q&#xff1a;乐维MCM免费版还需要再单独安装一个Zabbix连接么&#xff1f; A&#xff1a;估计是perseusZ_server服务进程因为重复安装导致服务挂了。试下能不能启动起来&#xff1f;service perseusZ_server status Q&#xff1a;正常启动的&#xff0c;页面刷新了还…

谷粒商城实战笔记-50-51-商品分类的删除

文章目录 一&#xff0c;50-商品服务-API-三级分类-删除-逻辑删除1&#xff0c;逻辑删除的配置1.1 配置全局的逻辑删除规则&#xff08;可省略&#xff09;1.2 配置逻辑删除Bean&#xff08;可省略&#xff09;1.3 Bean相应字段上加上注解TableLogic 2&#xff0c;后台接口开发…

力扣高频SQL 50题(基础版)第十题

文章目录 力扣高频SQL 50题&#xff08;基础版&#xff09;第十题1661. 每台机器的进程平均运行时间题目说明思路分析实现过程准备数据实现方式结果截图总结 力扣高频SQL 50题&#xff08;基础版&#xff09;第十题 1661. 每台机器的进程平均运行时间 题目说明 表: Activity…

数里行间创始人郭振:AIGC如何打造跨境电商增长新引擎

导读&#xff1a;在跨境电商领域&#xff0c;AI客服已经能够实现50%的问题平均解决率和不低于人工标准的客户满意度。 在生成式AI的商业化应用领域&#xff0c;跨境电商行业一直走在前列&#xff0c;成为最早实现技术落地的先锋之一。 “我们的AI客服机器人&#xff0c;已经帮助…

01 MySQL

文章目录 MySQL1、数据库相关概念2、MySQL3、SQL概述4、DDL:数据库操作5、DDL:表操作6、DML7、DQL8、约束9、数据库设计10、多表查询11、事务 MySQL 1、数据库相关概念 数据库 &#xff08;1&#xff09;存储和管理数据的仓库&#xff0c;数据是有组织的进行存储。 &#xff0…

【接口测试】params传参与body传参区别

文章目录 一.params传参二.body传参三.两者区别说明 一.params传参 params传参一般用于get请求 params传参时,参数会附于URL后面以问号形式展示。 示例&#xff1a; http://ip地址:端口号/login?usernamexm&pwd111二.body传参 body传参一般用于post请求 body传参时需…

2022真题-架构师案例(二)

1、某大型电商平台建立了一个在线B2B商店系统&#xff0c;并在全匡多地建设了货物仓储中心&#xff0c;通过提前备货的方式来提高货物的运送效率。但是在运营过程中&#xff0c;发现会出现很多跨仓储中心调货从而延误货物运送的情况。为此&#xff0c;该企业计划新建立一个全国…

搜维尔科技:Cyber​​glove通过其前所未有的柔性传感器技术,带来了多年的经验、专业知识和可靠性

Cyberglove 概述 新一代数据手套技术 MoCap 手套采用了原始 CyberGlove 产品 20 年经验所建立的技术&#xff0c;产生了改进的和新的特性、能力和设计&#xff0c;非常适合动作捕捉环境。 旧与新相遇&#xff0c; Cyberglove 通过其前所未有的柔性传感器技术&#xff0c;带来…

【深度学习】PyTorch框架(5):Transformer和多注意力机制

1、引言 在本文中&#xff0c;我们将探讨近两年来最具影响力的模型架构之一——Transformer模型。自从2017年Vaswani等人发表的论文《注意力是你所需要的全部》以来&#xff0c;Transformer架构在多个领域持续刷新着性能记录&#xff0c;尤其是在自然语言处理&#xff08;NLP&…

【网络安全的神秘世界】 文件上传及验证绕过

&#x1f31d;博客主页&#xff1a;泥菩萨 &#x1f496;专栏&#xff1a;Linux探索之旅 | 网络安全的神秘世界 | 专接本 | 每天学会一个渗透测试工具 这个漏洞对于初学者好挖&#xff0c;先找到文件上传的位置 文件上传是web网页中常见的功能之一&#xff0c;通常情况下恶意文…

Windows 11 系统对磁盘进行分区保姆级教程

Windows 11磁盘分区 磁盘分区是将硬盘驱动器划分为多个逻辑部分的过程&#xff0c;每个逻辑部分都可以独立使用和管理。在Windows 11操作系统中进行磁盘分区主要有以下几个作用和意义&#xff1a; 组织和管理数据&#xff1a;分区可以帮助用户更好地组织他们的数据&#xff0c…

怎么使用动态IP地址上网

如何设置动态IP地址上网&#xff1f; 设置动态IP地址上网的步骤如下&#xff1a; 一、了解动态IP地址 动态IP地址是由网络服务提供商&#xff08;ISP&#xff09;动态分配给用户的IP地址&#xff0c;它会根据用户的需求和网络情况实时改变。相比于静态IP地址&#xff0c;动态…

闲鱼、抖音、快手纷纷入局,“谷子”经济千亿市场纷争再起

二次元的狂热&#xff0c;终于还是蔓延到了三次元。 此前&#xff0c;在咸鱼上的一场拍卖中&#xff0c;一块直径75mm&#xff0c;重达20克的“吧唧”&#xff08;徽章&#xff09;&#xff0c;以7.2万人民币的价格被成功拍下。而与此同时&#xff0c;上海黄金交易所中的金价是…

Three.js 官方文档学习笔记

Address&#xff1a;Three.js中文网 (webgl3d.cn) Author&#xff1a;方越 50041588 Date&#xff1a;2024-07-19 第一个3D案例—创建3D场景 创建3D场景对象Scene&#xff1a; const scene new THREE.Scene(); 创建一个长方体几何对象Geometry&#xff1a; const geomet…

【C++】:AVL树的深度解析及其实现

目录 前言一&#xff0c;AVL树的概念二&#xff0c;AVL树节点的定义三&#xff0c;AVL树的插入3.1 第一步3.2 第二步 四&#xff0c;AVL树的旋转4.1 右单旋4.2 左单旋4.3 右左双旋4.4 左右双旋4.5 插入代码的完整实现4.6 旋转总结 五&#xff0c;AVL树的验证六&#xff0c;实现…

详细讲解vue3 watch回调的触发时机

目录 Vue 3 watch 基本用法 副作用刷新时机 flush 选项 flush: pre flush: post flush: sync Vue 3 watch 基本用法 计算属性允许我们声明性地计算衍生值。然而在有些情况下&#xff0c;我们需要在状态变化时执行一些“副作用”&#xff1a;例如更改 DOM&#xff0c;或是…

数字图像处理笔记(一)---- 图像数字化与显示

系列文章目录 数字图像处理学习笔记&#xff08;一&#xff09;---- 图像数字化与显示 数字图像处理笔记&#xff08;二&#xff09;---- 像素加图像统计特征 数字图像处理笔记&#xff08;三) ---- 傅里叶变换的基本原理 文章目录 系列文章目录前言一、数字图像处理二、图像数…

文件I/O基础

一、传统I/O数据传输过程 用户进程调用 read() 函数,发送上下文切换,用户进程由用户态切换成内核态,CPU向磁盘发起数据读取IO请求,然后返回;磁盘控制器收到请求,就开始准备数据,把数据放入磁盘控制器的内存缓冲区中,然后产生一个中断;CPU收到中断信号,停下手头工作,…

linux系统查历史cpu使用数据(使用sar 查询cpu和网络占用最近1个月历史数据)。

一 sar 指令介绍 在 Linux 系统中&#xff0c;sar 是 System Activity Reporter 的缩写&#xff0c;是一个用于收集、报告和保存系统活动信息的工具。它是 sysstat 软件包的一部分&#xff0c;提供了丰富的系统性能数据&#xff0c;包括 CPU、内存、网络、磁盘等使用情况&am…

802.11 Omnipeek 抓包

802.11 Omnipeek 抓包 前言Omnipeek安装软件配置 前言 设备准备环节和前面一样&#xff0c;本文不再赘述&#xff0c;参考前面的文章&#xff1a;https://blog.csdn.net/m0_55334946/article/details/140671901 采用 Omnipeek 抓包分析&#xff0c;我可以说比起 wireshark 已…