JUC并发编程笔记(一)

目录

进程与线程

进程与线程区别

并发和并行

Java线程

创建和运行线程

方法一:使用Thread

[方法二:Runnable + Thread](#方法二:Runnable + Thread)

线程运行原理

[方法三:FutureTask 配合 Thread](#方法三:FutureTask 配合 Thread)

远程监控Java线程的工具jconsole

线程运行原理

栈帧(代码debug视图)

栈帧图解

多线程情况

线程上下文切换

常见方法

[start() 和 run()](#start() 和 run())

[sleep() 和 yield()](#sleep() 和 yield())

线程优先级

案例:防止CPU占用100%

join

interrupt

涉及模式:两阶段终止

interrupt打断park线程

过时的方法

守护线程

线程的状态

五种状态

六种状态

线程安全问题

上下文切换(共享问题出错)

[临界区(Critical Section)](#临界区(Critical Section))

[竞态条件 Race Condition](#竞态条件 Race Condition)

synchronized解决方案

synchronize的理解

思考

锁对象改进-面对对象优化

synchronize的使用语法

线程安全的分析

​编辑

局部变量线程安全分析

局部变量引用

常见的线程安全类

线程安全类中的方法组合

不可变类线程安全

线程安全实例分析

Monitor概念

Java对象头

Monitor(锁)介绍

sychronized原理

小故事:引出轻量级锁,偏向锁

轻量级锁

锁膨胀

自旋优化

偏向锁

偏向锁状态

偏向锁的撤销

批量重偏向

批量撤销

锁消除

[wait notify](#wait notify)

工作原理

API介绍

[wait() 和 sleep() 的区别](#wait() 和 sleep() 的区别)

正确使用姿势


进程与线程

进程与线程区别

进程

首先我们知道程序是由指令和数据组成 ,一个程序的执行过程: 指令加载到CPU数据加载到内存 。指令的运行有时还会涉及 磁盘IO 和 网络IO进程就是用来加载指令、管理内存 和 IO的。

进程的创建:当程序被运行 ,将该程序的代码加载到内存中,即启动了一个进程。

**进程可以看作是程序的运行实例。**大部分程序可以同时运行为多个实例(如浏览器,记事本等),也有的程序只能运行一个进程实例(如微信,网易云音乐)。

Java中,进程 作为资源分配最小单位

线程

线程处于进程之内,一个进程可以有多个线程。

一个线程就是一个指令流,线程的作用就是将指令流中的一条条指令交给CPU执行。

Java中,线程为最小的调度单位

windows中进程是不活动的,只是作为线程的容器。

并发和并行

并发

在单核CPU下,我们来看CPU如何与多线程交互实现指令处理。

CPU只有单核 的情况下,多线程 实际上是串行执行的,因为一个CPU核心一次只能同时处理一个指令。

操作系统中有一个组件叫做 任务调度器,将CPU的时间片(约15ms)分给不同线程使用(时间片轮转算法),由于cpu在线程间切换处理的很快,因此我们人感知(人反应时间100ms)上才觉得是并行。

总结为:微观串行,宏观并行。

并发:多线程轮流使用CPU的做法就称为并发(concurrent)。

并行

并行就是存在多个CPU核心,同时处理不同线程的情况。

当然,即使在多CPU核心的情况下,依然会存在并发(串行)的情况。

也就是当线程数大于CPU核心时:这种情况即存在并行也存在串行。

Java线程

如下介绍在Java代码中如何创建和使用线程。

创建和运行线程

方法一:使用Thread

java 复制代码
@Slf4j
public class CreateAndRun {

    public static void main(String[] args) {

        // 线程的创建
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                log.info("t1-running");
            }
        };

        // 启动线程(将该线程交给任务调度器等待分配CPU执行)
        t1.start();
        
        // 主线程执行
        log.info("main-running");

    }
}

方法二:Runnable + Thread

java 复制代码
// 将任务定义在 匿名内部类中
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        log.info("runnable 1");
    }
};

Thread r1 = new Thread(runnable, "r1");
r1.start();

将任务的定义和线程的创建分开,方式更灵活。

可以使用lambda表达式简化书写:

java 复制代码
// () ->{} ,只要一行代码,可以省略 {}
Runnable runnable = () -> log.info("runnable 1"); 

Thread r1 = new Thread(runnable, "r1");
r1.start();

什么时候可以使用Lambda表达式简写?

当接口为 " 函数式接口 "时,可以使用lambda表达式(jdk8后)
函数式接口如何知道?

接口中只有一个抽象方法 ,或者看接口上是否有@FunctionalInterface注解

线程运行原理

根据上面的两种方法原理说明。

Java线程Thread对象创建运行后都是执行run方法。

使用Runnable + Thread 的方式创建线程:

看一下Thread类中的源码:

当有传入Runable对象后,默认执行Runnable对象中的run()方法。

而我们直接 new Thread 创建线程:

直接重写了父类中的run()方法,当线程启动,直接运行该run方法。

在Java中提倡 " 组合优于继承 ",所以比较推荐 Runnable + Thread 这种任务和线程分离的写法,比较灵活( 脱离了Thread继承体系 ),易于在Runnable与更高级的线程池API搭配使用。

方法三:FutureTask 配合 Thread

java 复制代码
// 创建任务对象
FutureTask<Integer> futureTask = new FutureTask<>(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        log.info("future...");
        Thread.sleep(1000);
        return 100;
    }
});

Thread thread = new Thread(futureTask);
thread.start();

// 阻塞等待异步线程返回结果
log.info("异步返回结果:{}", futureTask.get());

该方式可以用来处理异步线程具有返回值的情况。

远程监控Java线程的工具jconsole

在java程序执行时配置好信息,在windows端执行jconsole图形化界面程序,输入连接信息即可实现监控。

线程运行原理

栈帧(代码debug视图)

当执行上述代码,总共会执行三个方法:mian, method1, method2 ,从debug的视图看,会看到三个栈帧,执行完后方法后会自动释放掉对应的栈帧。

栈帧图解

开始执行代码时,虚拟机会将类中的字节码加载到方法区。

然后将第一行代码赋予给"程序计数器"开始执行代码,首先会执行main方法,给main方法创建一个栈帧内存,发现main方法中调用了method1()方法,然后会创建method1方法的栈帧内存。

创建栈帧内存的时候也会把方法内用到的变量内存预先创建好,然后随着代码逐步执行(程序计数器往下)到末尾就通过"返回地址"返回上一步代码调用处,释放掉已经执行完毕的方法栈帧。

多线程情况

多线程的栈内存相互独立,不同线程同时执行同一套代码,会创建出多个相互独立的栈帧内存。

线程上下文切换

常见方法

start() 和 run()

**start:**代表启动线程

run: 代表线程启动后要执行的代码。

  • 不可直接调用线程对象的run方法,必须通过调用start()方法来执行run()方法中的代码,如果直接调用run方法,run方法中的代码实际上由主线程( main )来运行而不是新线程,达不到异步处理的效果。
  • 在start()方法执行后,线程的状态会从NEW 转到 RUNNING
  • 不可多次调用**start()**方法,否则会报线程状态异常的错误。

sleep() 和 yield()

可以使用TimeUnit的sleep方法替换Thread的sleep方法:

关于sleep和yield的区别,sleep调用后会进入阻塞状态,在睡眠时间内不可能在分配CPU资源给线程,而yield执行之后进入的是"就绪"状态,是否分配CPU资源实际上是由任务调度器决定。

线程优先级

优先级在Thead类源码中定义为1-10等级,默认为5。

调度器对于优先级的处理策略跟yield差不多,比较难控制。

案例:防止CPU占用100%

通过实践测试得出结论:当没有使用sleep优化时(单核CPU的条件下),占用率达到95%以上,而使用sleep优化之后,占用率降到3-5%。

join

为什么需要join

查看这段代码,执行结果输出为0。

如果我们想要主线程等待t1线程执行结束后再往下执行输出r变量,就需要join。

也就是一个线程需要等待另一个线程结束或需要获取另一个线程的执行结果再运行的情况,可以使用join。

优化后(变成同步)

join()就是将线程进行阻塞,当调用join的线程执行结束后就会往下继续执行。

限时同步

join()还有一个带参的方法,可以输入最大等待时间。

interrupt

打断阻塞的线程

在sleep, wait , join 的状态时如果使用interrupt进行打断,那么会报出异常并将打断标记清空,设置为false。

在线程正常运行时也可以被打断,被打断之后打断标记会被设置true。用打断标记来标记线程运行中被打断过,而阻塞的线程被打断则可以在异常捕获中进行处理判断。

涉及模式:两阶段终止

两个判断是否被打断的方法,区别在于是否会清楚打断标记(将标记设为false)

interrupt打断park线程

**park线程作用:**让当前线程停下来。

控制台:

执行到LockSupport.park()之后,线程会进入到阻塞状态(停下来),使用Interrupt()可以将这种状态进行打断。

重复执行park():

如上代码,在将线程进行打断之后(打断标记设为true),然后再次调用park()的话不会生效,因为park有一个特性:就是只有打断标记为false时才会生效。

那么,如何让多次park都生效呢?使用如下方法即可。

interrupted()方法在返回打断状态之后会自动将打断标记进行清除(打断标记设置为false)

过时的方法

守护线程

线程的状态

对于线程的状态问题的说法,众说纷纭,一般有五种或六种说法,如下进行解释:

五种状态

五种,这是从 操作系统的层面来描述:

六种状态

这是从 Java API层面描述的情况(从Thread类中可以看到):

线程安全问题

上下文切换(共享问题出错)

输出的结果不为0

但是在多线程并发情况下,指令会交错执行导致结果与逾期不符。

临界区(Critical Section)

例如,如下代码的临界区:

竞态条件 Race Condition

多个线程临界区内执行 ,由于代码的执行序列不同 导致结果无法预测,称之为发生了竞态条件

那么我们在临界区内要如何避免产生竞价条件呢?

synchronized解决方案

实际使用解决上面的问题:

synchronize的理解

比喻说明

思考

问题1: 将synchronized放到for的外面,实际上就是对for以及counter++代码做了 原子性保护。

将原子性保护范围扩大了。

**问题2:**如果锁对象不一样,那么是达不到对临界区代码的保护效果的,因为如果锁对象不一样,那么在第一个线程拿到锁A(对象A)加锁然后执行临界区代码时,这时线程二进来想要执行临界区代码,正常来说这时如果锁对象相同那么就会发现锁对象已经被取走并加锁了,然后线程二就该阻塞,但是如果锁对象不一样,那么线程二就能够取到没人认领的锁对象并自己在次加锁,这样就达不到对于临界区代码的保护。

问题3: 如果一个加了而另一个没有加,那么跟问题二的情况类似,没有办法阻塞住第二个进来执行临界区代码的线程。

锁对象改进-面对对象优化

在上面的锁代码中,代码是基于面向过程的写法,因此接下来我们可以将需要共享的资源和会造成竞价条件的方法封装到类中。

将需要共享的counter放到Room类中充当成员变量,并声明加减方法和获取方法(都需要加锁,锁是自身对象)

使用:

**该方式:**将共享资源的保护(互斥操作)放到类内部实现,外部只需要调用方法即可。

synchronize的使用语法

在上面的synchronize使用中只是其中一种用法,以下介绍其他使用方法。

加到成员方法上

因此可以对上面的类进行改造:

将synchronize放到方法上,锁住的是 方法当前调用的对象,也就是this对象并不是锁住方法。

加到静态方法上

**需要注意:**关于静态方法和普通方法加上synchronize关键字后的作用区别:在静态方法上加上synchronize时锁对象为类对象,而在普通方法上加synchronize时锁对象为this对象,看如下:

如上代码的锁对象实际上不一样(static的方法锁对象为Number.class,而普通方法锁对象为n1对象),所以达不到对代码的原子性保护。

关于"线程八锁"的问题:上面的代码就是其中一个情况说明,线程八锁问题主要的核心是要知道判断锁对象是否相同,从而判断是否能达到互斥的效果,以达到代码的原子性保护。

线程安全的分析

局部变量线程安全分析

如上关于 i 局部变量,当多个线程同时调用test1方法时不会产生线程安全问题,解释:

test1方法中的 i 局部变量会在每个执行线程的栈帧内存中创建,因此每个线程都会拥有一份 i 变量,在不同线程中操作的 i 对象不是同一个,不存在共享不存在线程安全问题。

局部变量引用

成员变量需要考虑线程安全问题,看一段代码:

执行测试:

将上面的成员变量变成局部变量再测试:

也就是将list放到方法中,就会解决线程不安全的问题

将局部变量暴露给外部的情况

如上定义了一个子类去继承线程安全的父类,如果子类中如上声明了一个新线程去操作父类中的局部变量(前提是子类可以重写父类的public方法),也会造成线程安全问题。所以如果不希望子类影响到父类的方法,可以将方法声明为Privte或加上final关键字以保护安全。

这个例子可以看出private或final提供【安全】的意义所在,体会开闭原则中的【闭】。

常见的线程安全类

线程安全类中的方法组合

线程安全类中的方法线程安全的实现是在方法上加了sychronize。

可以看到虽然线程安全类中的方法是线程安全的,但是如果组合调用,就不是线程安全的了,需要在方法组合调用处加上线程保护。

不可变类线程安全

对于如上问题,当调用replace这样的方法时,实际上内部是重新new了一个对象返回(变化的对象),原先的对象依然不变。

线程安全实例分析

如上的解决方法:使用环绕通知,将共享属性作为局部变量放到方法中。

如上的写法是线程安全的,因为DAO中没有成员变量很好判断,没有共享的资源,不会产生线程安全的问题。

如上的写法就有问题了,因为在Dao中Connection是成员变量,多个线程可同时访问,举例:当线程1创建了一个connection还没用,线程2就进来又创建了一个新的,这是有问题的。

如上会发生线程安全问题,因为即使属性是局部变量,但是【局部变量的引用泄露了】,暴露给了其他方法,在foo方法的实现中,发现使用了new Thread()创建了新线程,会导致多个线程访问同一资源的问题。

Monitor概念

Java对象头

以下以32位的虚拟机为例:

对象头是对象的一个组成部分,占用8个字节。

其中Mark Word 和 Klass Word个占4个字节。

Klass Word中存储着一个指针,指定对象所属类型(属于哪个类)。

而Mark Word存储着该对象更多的信息:

其中在不同状态下所存储的东西是不同的,当我们加了一些锁,或是被GC回收状态就会变。

Monitor(锁)介绍

当我们使用sychronized()之后,线程执行到代码就会去检查对象是否绑定monitor:

Monitor结构:

sychronized原理

从字节码的角度去理解,加锁解锁sychronized的过程:

**重点:**在读到sychronized关键字行代码的时候,会复制一份对象原来的引用(含有Hashcode等信息),然后将引用进行更改为Monitor对象的地址,最后执行完逻辑代码后直接返回结束。

看到16有指令 goto 24 ,这是代码正常运行的时候走的路径,如果逻辑代码出现异常,则执行19-23行指令去解锁,所以使用sychronized不必担心死锁问题

小故事:引出轻量级锁,偏向锁

故事角色:

轻量级锁

轻量级锁比Monitor锁的效率更高,因为轻量级锁只是用线程栈帧中的锁记录代表锁,不需要而外申请Monitor锁。

对于上述两次加锁 代码的图解

锁膨胀
自旋优化

为了不让参与竞争的线程直接进入entry list进行的优化,让竞争线程在进入之前进行几次循环等等看。因为如果将本次时间片丢掉直接进入entry list的话就直接阻塞了,会发生上下文切换。

偏向锁

偏向锁状态

回忆一下再看对象头格式各状态示例图:

从下往上看

  1. 锁为 Monitor锁(重量级锁) 的时候,62位存储的是Monitor锁地址,状态为10。

  2. 锁为 轻量级锁 时,62位存储是线程栈帧中锁记录地址,状态为00。

  3. 锁为 偏向锁 时,54位存储着线程IDbiased_lock值为1,状态为01。

  4. 当是无锁状态 时,mark word中正常存储对象相关数据,如hashcode,状态为01,和偏向锁区分的一点为biased_lock为0。

查看对象头:使用库 jol 这个官方提供的jar包

使用此代码打印出来的对象头,最后三位显示001,与我们的结论不符。因为上面说到偏向锁是具有延迟性的。我们可以使用sleep几秒后再试:

查看使用对象作为锁对象时对象头中的mark word的变化:

可以看到,第二个打印时使用了sychronized关键字给对象加了偏向锁,因此后10位显示其他数值,剩下的为线程ID,就算第三次打印再解锁之后,依然会显示线程ID,因为这就是偏向锁对于轻量级锁的优化。

**值得注意的是:**这里的线程ID与我们直接调用getTheadId()方法获取线程ID的值是不一样的。

禁用偏向锁测试

当我们禁用偏向锁后,上来默认就使用轻量级锁,当解锁之后,恢复无锁状态:

偏向锁的撤销

获取hashcode时

当我们调用获取对象的hashCode的后,对象会自动撤销对象的偏向锁使用,将hash码填到对象mark word中,因为不论hash码还是线程ID想要填写到mark word中时,都在使用时进行填写,而当我们调用获取hash码时就要向mark word中填写hash码,此时存储位基本被占满,没有thread id 的存储位了,因此只能撤销偏向锁,锁的使用就只能用到上一级的轻量级锁。

为什么使用轻量级锁或重量级锁时调用getHashCode不会被撤销?

因为无论是轻量级锁还是monitor锁都会将对象的hashcode进行替换/保存,当解锁时再恢复。

轻量级锁将hash code存储在线程栈帧的锁记录中。

而重量级锁则将hash code存在monitor锁对象中。

撤销:其他线程调用对象

当有其他线程也使用同一个锁对象时,那么偏向锁的偏向状态就会被取消,升级位轻量级锁。

结果显示直到第四次打印,依然是偏向状态,在第五次打印时,线程2使用了同一个对象,因此偏向状态被打破,升级为使用轻量级锁,轻量级锁解锁之后不会回到偏向状态,而是无锁状态。

撤销:使用wait / notify

因为wait / notify 等待通知机制只有monitor锁才有,所以只要使用就会升级为重量级锁。

批量重偏向

代码解读,线程1循环给30个对象加偏向锁,执行完毕后通知线程2,线程2再循环给30个线程加锁观察状态。

结果:线程1给30个对象加的全部是偏向锁,而线程2的前20个全部加的是轻量级锁,也就是前20个全部出现了锁撤销的情况,当到达阈值20之后,而就是线程2执行到第20个线程加锁的时候,jvm会认为"是不是偏向错了",因此再剩下的10个线程加锁时,会加上偏向锁,批量偏向线程2。

批量撤销
锁消除

java程序运行时,会默认开启锁消除的优化:当我们使用sychronized围绕锁对象时,如果锁对象没有被其他线程共享,那么会自动进行锁消除优化:即 忽略调这一次加锁。

我们可以使用包benchmark.jar来测试

用来查看方法执行时间:

锁消除的现象:就是方法b()中的synchronized(o)方法会被忽略调,因此测试结果为:a() 和 b() 方法的执行时间基本一致。

关闭锁消除优化 再测试:

发现当关闭jvm的锁消除优化后,方法b() 的效率相对a()差了十几倍。

wait notify

工作原理
API介绍

值得注意的是:notify() 方法是从waitSet中随机挑一个唤醒

带参数的wait()

**wait(long n)**有时限的等待,到n毫秒后结束等待,或是被notify

wait(long n, nano o):该方法源码中并不会将等待时间设置为纳秒级别,只是毫秒n++然后调用wait(long n)方法。

wait() 和 sleep() 的区别

重点就是是否会释放锁

以及状态都变成Time Waiting

**使用:**使用Object进行方法调用。

**虚假唤醒:**如果直接使用notify()进行随机唤醒,那么可能存在被意外唤醒的情况,被意外唤醒的情况就是虚假唤醒。

**解决:**使用notifyAll()或使用如下正确姿势进行处理。

正确使用姿势

当被notifyAll()唤醒时,使用标识变量和while()去判断是否为"虚假唤醒",是的话重新进行waitSet等待正确的唤醒。

相关推荐
claider1 小时前
Vim User Manual 阅读笔记 usr_12.txt Clever tricks 花招
笔记·编辑器·vim
CTO Plus技术服务中2 小时前
大厂面试笔记和参考答案!浏览器自动化、自动化测试、自动化运维与开发、办公自动化
运维·笔记·面试
koo3642 小时前
pytorch深度学习笔记17
pytorch·笔记·深度学习
丝斯20112 小时前
AI学习笔记整理(53)——大模型之Agent 智能体开发
人工智能·笔记·学习
梨子串桃子_10 小时前
推荐系统学习笔记 | PyTorch学习笔记
pytorch·笔记·python·学习·算法
laplace012312 小时前
# 第六章 agent框架开发实践 - 学习笔记
人工智能·笔记·学习·语言模型·agent
坚持不懈的大白13 小时前
Leetcode学习笔记
笔记·学习·leetcode
中屹指纹浏览器14 小时前
双GAN网络驱动的动态指纹生成技术深度解析——原理、实现与抗检测优化
经验分享·笔记
JeffDingAI14 小时前
【Datawhale学习笔记】基于Gensim的词向量实战
人工智能·笔记·学习