前面我们已经具体的介绍了一下线程的有关知识,知道了线程是怎么创建的,线程有什么样的作用,那么接下来就详细的展开讲讲如何运用多线程并发的方式来完成开发了——共享模型之管程

这个部分的内容是相对来说比较多的,可以根据需要来进行跳转阅读

共享问题

这个部分想要强调的就是一个变量被多个线程同时读取和修改的问题。

因此在这个共享区域的地方,是后续开发过程中如果要考虑高并发问题的话,需要重点考虑一下这个部分代码的编写!

接下来我们用一段代码来演示一下,顺便巩固一下前面学过的知识点!

package src.itheima.article2.test01;

public class test1 {
    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count--;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }
}

问题分析

为什么上面的代码运行的结果最后不为0呢?

解释count++

原因就在于count++不是原子性的操作:

  1. 需要先把count这个值给查出来
  2. 然后执行对应的运算操作
  3. 将这个运算的结果写回到count当中!

线程工作机制
  1. 首先是线程在工作的时候,会有一个主内存和一个工作内存,主内存里面存储了所有的对象信息,工作内存只存储了部分对象信息和很多临时信息(没有来得及同步到主内存中)
  2. 因此很多情况下,线程在执行任务的过程中,都是直接读取本线程的工作内存,而不是去到内存当中去读取数据,只有当需要的数据在本地内存中不存在的时候,才会去到主内存里面刷新本地内存
  3. 因此,上面的工作机制就存在一个问题!线程用到的数据可能是老数据,没有来得及进行更新,就有可能引发线程安全问题!(后续我们会介绍一个关键字volatile来保证变量的可见性,实现即使的同步数据功能,保证数据可见性)
  4. 上面提到的实际上就是有关于JMM内存模型的概念!在这里可以先了解一下就行!

【根本原因是上下文切换引起的指令操作异常!】

流程:

  1. 线程2从内存里面读取到变量i的值为0
  2. 准备好常数,然后对变量i进行-1操作,i=-1
  3. 这个时候发生了上下文切换!这个时候是线程1获取到了CPU的执行权来执行程序;
  4. 线程1读取到的值为0,做加法,然后将这个值写回到内存i=1
  5. 再接着,上下文切换到线程2,这个时候i在线程2里面是-1,写数据到内存,修改对应的值-1
  6. 导致最终结果出错

另外一种情况

join的使用

也许你会说:我们不是用到了join这个关键字了吗?不是可以保证在线程1结束后线程2才去接着运行吗?nonono,你理解可能错了,看下面的解释:

join() 的作用是让当前线程等待目标线程执行完毕

具体来说:

  • 线程 A 中调用线程 B 的 join() 方法,线程 A 就会阻塞,直到线程 B 运行结束(或超时)。

  • 它用于保证线程之间的执行顺序,例如主线程必须等待所有子线程完成后,再继续执行后续逻辑(如汇总结果)。

t1.join() 和 t2.join() 保证了主线程会等待两个线程都结束才打印 count,但这只能保证它们执行完成,并不能解决 count 的并发修改问题。

看了上面的解释后,或许你已经知道如何修改上面的代码,来保证最终结果是0了

static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                t1.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            for (int i = 0; i < 5000; i++) {
                count--;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }

临界区

先来简单捋一下思路:什么情况下会出现线程安全问题

  1. 首先一个程序如果里面运行多个线程是没有问题的,毕竟有的时候需要一些额外的线程去完成耗时比较长的工作!比如IO操作等
  2. 多个线程去读共享资源本身也不会出问题!
  3. 问题就出在多个线程对贡献资源读写操作时发生了指令交错,这个时候就会出现问题!
  4. 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

竞态条件

产生的原因就在于发生了线程之间的并发,多个线程同时对一个共享资源进行读写操作🚗,导致结果无法预测

synchronized解决方案

其实在Java当中,我们有非常多的方式来解决竞态条件的问题

有我们所熟知的synchronized悲观锁方案:认为线程安全问题一定会发生,因此一定要去执行上锁操作,但是上锁/解锁的开销相对来说还是比较大的,因此又进行了进一步的优化,例如我们后续会提到的偏向锁,轻量锁,重量锁这三把锁!

还有另外的一种CAS乐观锁方案:对于这把锁想必做个黑马点评这个项目的友友并不陌生,认为线程安全问题不一定会发生,只需要做好数据的校对就好了!后续我们会深入的讲讲乐观锁的使用问题!

介绍一下synchronized:

  1. synchronized的使用就是为了避免临界区的静态条件的发生!相当于是准备好了一把锁,只有获取了这把锁的线程才会去执行对应的任务
  2. synchronized锁的方式是阻塞式的,当线程1获取到了锁并开始执行任务的时候,线程2这个时候如果过来获取锁的话,那么肯定是获取失败的!因为synchronized是互斥锁,线程2因此进入阻塞态BLOCKED状态进行等待

语法解释:

  1. 在这个部分的话,如果线程1获取到了锁,那么后续线程2再来获取锁就会进入到blocked的状态,等待锁的释放
  2. 线程1执行临界区部分的代码,在执行完成之后,锁就会自动释放,线程2拿到对应的锁开始执行后续的程序

如果线程1和线程2上的不是同一把锁的话,是不能保证当前线程运行的过程中不会发生线程安全问题!因此一定要保证上对锁!

方法上的synchronized

主要是有以下两种:加在静态方法上和加在非静态方法上

  1. 如果是加在普通方法上的锁,本质上就是在这个方法内部加上synchronized来使用这个代码块
  2. 如果是静态方法的话,上锁的对象就是这个类了,需要注意一下这两者的区别

线程安全分析

如果多个线程同时执行这个方法的话,对于内部的变量i是没有影响的

  • 每个线程在执行的过程中,都会创建自己的栈帧,相对应的i也会保存在对应的栈帧当中,是不会进行共享的,因此也不会有线程安全的问题!

线程安全类

下面的这些类是线程安全的,多个线程调用同一个实例,也可以保证线程是安全的!

为什么是线程安全的呢?

因为这些安全类的内部,大多都使用了synchronized来进行修饰,可以保证这些类是安全的!

举个例子:

为什么Hashtable是线程安全类?

  1. 因为这个类的内部的方法都使用了synchronized来进行了加锁操作
  2. put和get加锁,如果其中一个线程获取到了锁就会执行对应的代码,其他的线程如果尝试获取锁,就会陷入阻塞状态,保证了线程的安全

线程安全类方法的组合

  1. 对于线程安全类的单个方法来说,是可以保证线程安全的!【单个方法内部,可以保证线程业务的安全执行】
  2. 但是如果将这几个方法组合到一起的话,就不一定是安全的了

解释:

  1. hashtable的线程安全是方法级别锁,都是在方法的级别上加的锁
  2. 比如get方法,在执行这个方法的时候,可以保证单个方法调用的原子性;但不能保证这一系列组合的原子性

不可变类线程安全性

这里以string为例来进行解释

当我们在string内部来执行这些方法的时候,在底层是new了一个新的string,而不是在原来的基础上进行更新,保证了线程安全性

Monitor原理

介绍:

  1. 由于我们在线程安全问题当中引入了锁的概念,因此我们需要一个管理者来管理这些锁,需要一个监视器
  2. 一开始的时候monitor为空,thread2执行了上锁的操作之后,monitor的owner就变成了线程2,monitor当中只有一个owner
  3. 后续其他的线程来获取锁的时候,都会进入到entrylist当中,变成阻塞态
  4. 后续在线程2执行完对应的业务代码之后,会执行释放锁的操作,唤醒entrylist里的线程去竞争锁

synchronized原理进阶

轻量级锁

适用于没有竞争的情况,多线程的访问是错开的!

Mark Word结构对于后续深入了解对应的锁机制是非常有帮助的

这里的Mark Word结构是32位的

主要是通过后两位来判断当前锁的状态,来决定该锁是不是能被其他线程获取到

流程介绍:

  1. object就是这个锁对象!
  2. 这个流程目的是,去判断一下object的 Mark Word的后两位的位数的情况,如果是01状态,则说明当前锁是闲置的状态可以获取的,那么就去获取这个锁,并且改变一下Mark Word的后两位的状态
  3. 在进行了交换之后,object reference需要去指向这个object
  4. 如果cas交换成功,那么就成功上锁了【cas就是用来交换锁和对象的状态的】

如果cas交换失败,需要考虑下面两种情况

  1. 第一种肯定有其他的线程占用了这个锁,当然就没有办法来获取锁了
  2. 第二种有可能是当前线程里面有一个方法A已经是获取到了锁了,那么方法B可以执行锁冲入操作,再添加一条lock record作为重入的计数

锁膨胀

流程

  1. 其实一开始再没有线程1加入的时候,默认是轻量级锁
  2. 当线程1来尝试获取锁的时候,发现获取锁失败,因此锁升级为重量级锁;这个时候就需要引入对应的monitor来实现重量级锁的操作,比如重新确定owener和对应的entrylist部分
  3. 线程1这时候进入阻塞态,进入到entrylist当中
  4. 最后是线程0释放锁,发现cas失败;识别到锁已经升级为了重量级锁,因此需要修改对应的owner为null,并且将entrylist里面的线程执行唤醒操作

自旋优化

简介:

  1. 可以理解为在原来的重量级锁的基础上,加上了失败尝试的机制!【自旋优化部分】
  2. 如果线程获取锁失败的话,就去进行失败尝试,失败尝试的次数需要根据当前的业务需求来确定,如果一直进行自旋操作的话,会占用CPU的资源,因此在自旋达到一定次数之后就应该放弃了【一扇敲不开的门,敲多了就不礼貌了】
  3. 如果自旋成功了,那么就启动当前的线程,如果多次自旋仍然失败的话,那么就进入阻塞态

偏向锁

简介:

  1. 这个锁主要是用在一开始没有线程来竞争的时候,就是只有一个线程在使用这个锁的情况
  2. 即使没有其他的锁来竞争,每次冲入都需执行CAS操作
  3. 后续JAVA6对这个机制进行了改进,只有第一次使用CAS将线程ID设置成对象的Mark word头,之后再调用方法,发现这个线程ID是自己的就没有竞争,不用重新CAS操作

整个的流程:

  1. 一开始的时候是无锁的状态,可以直接进入进行使用
  2. 后来加上了锁,一开始是偏向锁,偏向于当前的对象,可以直接去调用
  3. 如果偏向锁被多次修改,那么就有可能发生锁的重偏向,偏向另外一个对象!
  4. 当然,在使用的过程中,锁会逐步的从偏向锁升级为轻量级锁和重量级锁

保护性暂停

park & unpark

简介:

有点类似于之前学到的wait和notify方法,也是暂停线程和唤醒线程的作用

park的话是暂停当前线程的使用,unpark的作用是唤醒某一个指定的线程

解释:

如果提前去执行了unpark操作,那么后续的park操作就没有办法去暂停对应的线程!

场景1:

  1. 就是一开始执行park操作的时候,会先去调用对应的方法
  2. 检查一下counter部分,如果为0,当前已经没有剩余的干粮了,因此需要停下来
  3. 接着进入阻塞态,设置counter=0

场景2

场景3

线程状态的转变

多把锁

简介:多把不相干的锁:

  1. 如果本来两个操作之间就没有什么关联的话,可以考虑将这两个操作分别并行的去执行对应的代码
  2. 需要考虑一下如何设置多把锁,保证他们可以并行的执行?

需要考虑的问题:

  1. 有可能会导致死锁
  2. 比如线程A拥有资源A,线程B拥有资源B;但是线程A需要资源B,线程B需要资源A,那么就会导致一个问题,导致线程之间需要对方的资源,因此造成了死锁的发生

活跃性

死锁

一种方法:使用jps

  1. 首先我们需要去新建一个终端,然后在终端里面输入jps,获取到所有的进程和对应的类
  2. 然后是使用jstack去针对性的访问对应的进程id,看看具体的内部情况

哲学家就餐问题

饥饿

顺序加锁来解决死锁问题:

  1. 我们可以让AB两个线程按照对象A和对象B的先后顺序去尝试着获取锁
  2. 首先是线程A获取到了对象A的锁
  3. 然后是B来尝试获取对象A的锁,发现没有办法获取到对应的锁,因此就阻塞住了【手里没有对象B锁】
  4. 接着是线程A接着去获取对象B,执行完成任务之后释放锁。
  5. 线程2按照相同的顺序去获取锁

坏处:

容易产生饥饿问题:如果线程的优先级很低的话,就有可能导致一直获取不到锁,该线程的任务一直不会执行,出现了饥饿的现象

ReentrantLock

特点:

  1. 如果设置为公平锁的话,可以很好的避免饥饿情况的出现
  2. 支持多个条件变量,可以类比前面提到的多把锁。

可重入

可打断

代码解释:

  1. 在这个部分的话,首先需要注意的是,需要声明一个可以被打断的锁,而不是一个普通的lock,普通的lock是没有办法进行打断操作的
  2. 接着是在异常处理的部分就可以直接进行退出了,不需要接着去往下接着运行了

为什么要加上可打断锁?

可以将锁进行打断操作,避免锁无限制的等待下去,避免死锁情况的发生!

锁超时

解释代码:

  1. 首先是尝试着去获取锁
  2. 如果获取锁失败的话,就会自动的进入if分支;
  3. 【不带参数】如果获取不到锁,直接返回。
  4. 【带参数】如果在设置的时间段内锁被释放了,那么就会正常往下执行,如果没释放锁,就没有办法往下运行了,结束
  5. 这个锁同样是可以被interrupt打断的!

解决哲学家就餐问题的核心代码!

公平锁

部分源码:

默认情况下是不公平锁,我们可以通过主动设置的方式来开启公平锁!

条件变量

简介:

  1. 一开始使用的锁是将这些阻塞的线程统一的放到一个阻塞队列里面,当锁被释放的时候,就会随机的唤醒这里面的某一个阻塞状态的线程
  2. 如果使用了条件变量的话,会针对性的唤醒那些符合条件的锁;就是相当于在原来的基础上,更加细致,专门去唤醒某一部分的锁即可!

具体使用:

  1. 在这个部分的话和前面学过的wait是类似的;
  2. 如果要执行await操作的话,首先你需要获取到锁,然后才能进入到对应的变量里面去等待
  3. 然后是释放锁的话,也是类似前面学过的notify知识点,可以随机的唤醒这个变量里面的某一个线程,也可以使用notifyall,类比这个部分的代码即可!其实都是差不多的
  4. 唯一的不同点就是,这个锁变量支持创建多个对象

设计模式

固定运行顺序

wait&notify

代码逻辑:

  1. 我们的业务需求是先2后1,因此代码在执行的过程中,需要去判断一下线程2是否执行了没有
  2. 线程1获取到了锁,然后去用一个while循环来看一下线程2是否运行了,如果运行了,那么就可以接着往下执行,否则执行wait,然后释放锁
  3. 线程2去执行,执行完成后,唤醒线程1继续往下执行即可!

park&unpark

交替输出

await&signal

park&unpark

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐