该博客只是个人学习的笔记。如果有什么疑问或者有什么不对的都可以告诉我,目前只写了多线程和锁的部分,因为只是个人学习记录的笔记,所以写的不是很详细,里面有一些个人的见解思考供各位参考。
一、多线程
关于什么是线程,什么是进程,这种概念我就不多叙述了,浅写一下个人觉得重要的叙述。
进程关注的是内存的使用,在进程被创建好的时候,每个进程在内存中都分配好了一块属于自己的内存。所以不同进程之间交换数据还是比较困难的,但是也有办法(管道,socket通信等)。而线程是属于进程的,属于一个进程的不同线程它们可以共享进程的资源,所以线程并不关心内存的使用,它们更专注于CPU的使用,一个CPU同一个时刻只能处理一个线程。所以这也是那句经典的话,进程是资源分配的最小单位,线程是CPU调度的最小单位。
上图是线程的状态图。需要注意的是,线程只能从就绪态到运行态,sleep和wait都会让当前线程陷入等待,但是wait会释放该线程拥有的独占锁,sleep不会。wait要在同步代码里使用,且通常需要和notify配合。且需要注意两点:
-
wait的两个方法都需要注意中断的问题,wait中断是从语句处中断并且释放锁,当再次获得锁时是从中断处继续向下执行。
-
notify 和 notifyAll方法通知是延迟通知,必须等待当前线程体执行完所有的同步方法/代码块中的语句退出释放锁才通知wait线程。(notify和notifyall不知道是什么的去百度吧hh不想写这么细节了,个人笔记默认是有基础的人看)
分享一个代码案例来讲解一下这两个注意点:
上图是我一年半前在leetcode做的第一道题(和一般人梦开始的地方是两数之和有点不一样hhh),看题意可知要求按序打印。我写的代码如下:
public class Foo {
private volatile int flag = 1;
private final String object = new String();
public Foo() {
}
public void first(Runnable printFirst) throws InterruptedException {
synchronized (object) {
while (flag != 1) object.wait();
printFirst.run();
flag = 2;
object.notifyAll();
}
/*
synchronized (object) {
if (flag != 1) object.wait();
printFirst.run();
flag = 2;
object.notifyAll();
}
*/
}
public void second(Runnable printSecond) throws InterruptedException {
synchronized (object) {
while (flag != 2) object.wait();
printSecond.run();
flag = 3;
object.notifyAll();
}
/*
synchronized (object) {
if (flag != 2) object.wait();
printSecond.run();
flag = 3;
object.notifyAll();
}
*/
}
public void third(Runnable printThird) throws InterruptedException {
synchronized (object) {
while (flag != 3) object.wait();
}
printThird.run();
}
/*
synchronized (object) {
if (flag != 3) object.wait();
}
printThird.run();
*/
}
如果将同步代码里的while改成if运行结果就会出错,如下图所示:
因为假设当线程3拿到被notify唤醒且拿到锁的时候,此时线程2还没有执行过,虽然此时flag≠3,它会释放锁进入等待,当下一次再被唤醒且拿到锁的时候,虽然此时线程2可能也还没执行,但是它会直接在上次中断的地方之后执行,也就是直接执行打印"3"。但是如果是while的话,它再拿回锁,因为还没有跳出while循环,所以它会再次进行判断是不是flag=3,如果不是,再wait。这个案例很好的解释了注意的第一点。至于第二点更容易理解了,假如我的first方法中,flag=2和object.notifyAll()的顺序换了一下,flag=2也还是会执行完才会通知wait该锁的线程。
1.创建线程的几种方法
第一种, 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。 调用线程对象的start()方法来启动该线程。
第二种,实现runabble接口, 并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。 调用线程对象的start()方法来启动该线程。
第三种,通过callable和future实现有返回值的线程
第四种,使用线程池创建。
推荐使用第四种。
2. 为什么推荐使用线程池创建线程?
1、降低系统资源消耗, 通过重用已存在的线程, 降低线程创建和销毁造成的消耗;
2、提高系统响应速度, 当有任务到达时, 无需等待新线程的创建便能立即执行;
3、方便线程并发数的管控, 线程若是无限制的创建, 不仅会额外消耗大量系统资源, 更是
占用过多资源而阻塞系统或内存不足等状况, 从而降低系统的稳定性。
3.创建线程池的七种方式
方法 | 含义 |
Executors.newFixedThreadPool() | 创建一个大小固定的线程池,可控制并发的线程数,超出的线程会在队列中等待 |
Executors.newCachedThreadPool() | 创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程 |
Executors.newSingleThreadExecutor() | 创建单个线程的线程池,可以保证先进先出的执行顺序 |
Executors.newScheduledThreadPool() | 创建一个可以执行延迟任务的线程池 |
Executors.newSingleThreadScheduledExecutor() | 创建一个单线程的可以执行延迟任务的线程池 |
Executors.newWorkStealingPool() | 创建一个抢占式执行的线程池 |
ThreadPoolExecutor() | 手动创建线程池,可自定义相关参数 |
l(25条消息) 创建线程池的七种方式_文丑颜不良啊的博客-CSDN博客_创建线程池
上面链接的这篇文章有demo讲解,推荐阅读。
Executors类提供了许多静态方法供我们创建线程池,但我个人平时喜欢使用还是最后一种,手动创建线程池的方法,可以自定义参数。
4.线程池的七个核心参数是什么?
刚才说了个人比较偏向于使用最后一种手动创建线程池,因为这样可以自己指定线程池的参数,具有灵活性。那么我们就来看看线程池的核心参数有哪些。
1.corePoolSize:线程池中的常驻核心线程数
2.maxinumPoolSize:线程池中能够容纳同时执行的最大线程数,此值必须大于等于一
3.keepAliveTime:多余的空闲线程的存活时间。
当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime时,多余空闲线程会被销 毁直到只剩下corePoolSize个线程为止。
4.unit:keepAliveTime的单位
5.workQueue:任务队列,被提交但是尚未被执行的任务。
6.threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程一般用默认的即可。
7.handler:拒绝策略,表示当队列满了并且工作线程-大于等于线程池的数量最大线程数时如何来拒 绝请求执行的runnable的策略。
常见的拒绝策略有哪些?(参考线程池有哪几种拒绝策略? - mzjnumber1 - 博客园 (cnblogs.com))
- 第一种拒绝策略是 AbortPolicy,这种拒绝策略在拒绝任务时,会直接抛出异常 RejectedExecutionException (属于RuntimeException),让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
- 第二种拒绝策略是 DiscardPolicy,这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。
- 第三种拒绝策略是 DiscardOldestPolicy,如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。
- 第四种拒绝策略是 CallerRunsPolicy,相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。
- 第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
- 第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
二、锁
因为同一个进程的线程可以使用该进程的所有资源,那么当不同线程使用同一个共享资源的时候就会发生并发事件,这时候通常就需要上锁来解决。
根据锁的类型不同可分为悲观锁,乐观锁。
乐观锁:顾名思义,就是很乐观,每次去取数据的时候都觉得别人不会修改,不上锁,只有在存数据的时候判断别人是否修改过了
悲观锁:总是很悲观,假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以要上锁(synchronized就是悲观锁)
1、乐观锁
乐观锁一般就是用 CAS来实现,但是如果多写的情况下可能会导致自旋等待次数过高,导致开销比悲观锁还大,所以乐观锁适合用于读多写少的情况,悲观锁有锁的开销,适合在写多读少的情况下使用。
ABA问题:
因为CAS在进行操作的时候,总是需要比较新的操作数和旧的操作数,如果相同则更新。但是如果新的操作数经过两次修改之后返回原来的值,那么久出现了ABA问题。解决问题的方法就是增加一个版本号,不仅仅通过检查值得变化来确定是否更新。
假设小琳银行卡有 100 块钱余额,且假定银行转账操作就是一个单纯的 CAS 命令,对比余额旧值是否与当前值相同,如果相同则发生扣减/增加,我们将这个指令用 CAS(origin,expect) 表示。于是,我们看看接下来发生了什么:
- 小琳在 ATM 1 转账 100 块钱给小李;
- 由于ATM 1 出现了网络拥塞的原因卡住了,这时候小琳跑到旁边的 ATM 2 再次操作转账;
- ATM 2 没让小琳失望,执行了 CAS(100,0),很痛快地完成了转账,此时小琳的账户余额为 0;
- 小王这时候又给小琳账上转了 100,此时小琳账上余额为 100;
- 这时候 ATM 1 网络恢复,继续执行 CAS(100,0),居然执行成功了,小琳账户上余额又变为了 0;
- 这时候小王微信跟小琳说转了 100 过去,是否收到呢?小琳去查了下账,摇了摇头,那么问题来了,钱去了哪呢?
CAS的全称为Compare-And-Swap,⽐较并交换,是⼀种很重要的同步思想。
juc包下的原子类已经写好了cas的方法,下面来看看这些方法底层的实现原理。
以AtomicInteger.getAndIncrement() ⽅法为例子,当我们看其源代码的时候就会发现,该方法没有加锁,也实现了同步的功能。是因为该方法调用了Unsafe类中的getAndAddInt() ⽅法。Unsafe 类的⼤部分⽅法都是 native 的,⽤来像C语⾔⼀样从底层操作内存。通过调⽤UnSafe类中的该CAS⽅法,JVM会帮我们实现出CAS汇编指令原语,原语在操作系统执行指令的过程中具有原子性,不可被中断,因为涉及到了过多的底层指令,我也不深入的了解了,但是其实CAS到了系统层面上它还是要加锁的,它通过锁地址总线或者使用缓存锁定来保证原子性的。所以其实在Java语法层面上来说,cas看似没有加锁,但是其实到了操作系统内核这步还是要考加锁来保证原子性的。但是这个和Java实现的悲观锁(synchronized lock())相比就是它虽然被加锁了,但是在操作系统层次上它不需要被阻塞住,减少了线程上下文切换的开销。
如果你去看一下AtomicInteger类的源码就会知道他的值是用volatile修饰的,但是它只能保证有序性、可见性、所以操作系统层面的cas原语就得保证了原子性。
2、悲观锁
2.1、synchronized
synchronized的特点:原子性,有序性,可见性,重入性。
- 修饰实例方法,用当前实例对象加锁,对象锁
- 修饰静态方法,类锁
- 修饰代码块,要指定加锁对象
修饰代码块的底层原理,jvm是通过对象监视器(monitor)来实现对方法和代码块的同步的,对象监视器本质依赖于底层操作系统
通过java-p反编译得到在代码同步块的的入口有monitorenter,出口有monitorexit。而同步方法是隐式的,只是在给用synchronized修饰的方法添加了标志,jvm通过该标志来判断方式是否是同步的。
在1.6对synchronized优化之后性能大大升级了,主要体现在两方面:
第一方面在编译方面:
可以进行锁消除(虚拟机分析不会产生数据并发竞争的情况就会将锁消除)还有锁优化(虚拟机分析到有一系列的连续操作都对同一个对象加锁,甚至加锁操作出现在循环中,那么将会把加锁同步范围扩展到整个操作的外部,就不用重复来给该对象执行加锁过程了)。
第二方面体先在运行方面: 无锁->偏向锁->轻量级锁->重量级锁。
synchronized锁升级过程:
其实很容易理解,首先知道为什么官方要设定一个这样的升级流程。因为大多数情况,刚开始的并发量都不多,甚至有时候都不会发生竞争,所以就通过设定在不同情况下采用的最合理的方式。下面来看看几种锁最适合的场景的时候以及它们升级的判定方式。下面表格没有写无锁,是因为无锁就是没有锁,这就不用说了。
偏向锁 | 当一个线程第一次获取到锁之后,再次申请就可以直接取到锁,不需要再加锁 |
轻量级锁 | 有多线程竞争,但是通过自旋一定次数就可以获得锁的情况。不需要等待太久。 |
重量级锁 | 有多线程竞争,线程一直自旋,长时间获取不到锁进入阻塞状态 |
无锁升级为偏向锁 |
|
偏向锁升级为轻量级锁 |
|
轻量级锁升级为重量级锁 |
|
2.2、ReentrantLock
reentrantlock的实现主要是其有内部类是AQS的实现类,默认是非公平的,下面我们看看AQS。
AQS
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。AQS它具有共享性和独占性两种模式。
AQS主要的三个地方,
- state 资源状态,使用volatile修饰的int变量,为0表示未被获取资源
- exclusiveOwnerThread 持有资源的线程,通过该变量判断持有的线程是不是自己来实现重入
-
CLH 同步等待队列。
为什么AQS的同步等待队列需要用到双向链表?
因为没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态(因为有可能加入等待队列的线程它被中断了呢,这个就是和synchronized不同的就是,AQS的阻塞线程可以被中断),这样设计是为了避免单向链表中中间存在异常线程导致无法唤醒后续线程的问题。所以每个加入同步等待队列尾部的线程都要先判断一下自己的前继节点是否是正常节点,不是就将其移除,是的话就进行加入尾部操作(CAS)。
什么时候AQS唤醒要从等待队列的尾部开始遍历,为什么要从尾部开始遍历?
当头节点要释放锁之后,开始进行判断头节点的下一个节点是否是正常节点,如果是的话,就将下一个节点唤醒就好了。如果不是呢,此时这个节点是异常的,如果按照正常逻辑依次往下遍历找到最近的一个非异常节点唤醒不就好了,可是因为等待队列的入队逻辑,有可能造成从头结点到尾节点方向的遍历是出现"断开的"。下面我们看看源码。
在高并发情况下,获取资源失败的线程插入到队列的尾部的时候都是通过CAS的,见上图中代码第11行的compareAndSetTail方法。当一个节点要入队尾的时候,它先在执行这个方法之前将该节点指向了现在的队尾节点。假设如果不这么做,在该方法执行之后才指向,也就是在if里面这么做,那么假如执行了if方法之后,该线程时间片到了进行上下文切换,这时候它虽然通过compareAndSetTail方法把自己变成了队列的尾部节点,但是它还没有指向原先的尾部节点,也就是自己的上一个节点,这时候链表就从后往前遍历“断开”了,找不到自己的前节点。
你可能会疑惑,那为什么将原先尾节点的next指向入队节点要放在该方法之后呢,因为如果放在之前的话,没有CAS设置尾节点成功的线程也被指向了,那不是乱了吗,但是那些没有进队成功的指向原先的尾节点,它不会对原来的队列正确性造成一点影响,因为尾部变量tail也不是它们。
所以看第12行代码,如果到此时该线程的时间片用完了,没执行到这一步,此时该队列又在别的地方遍历。那么确实是会出现从前往后遍历出现“断裂”找不到后续节点的情况。所以必须要从后往前遍历,就不会出现找不到的情况了。
AQS非公平锁怎么实现?
这上面就是AQS公平锁的一个流程,那非公平又是怎么实现的,其实和公平的差不多,只是在一两个步骤上有些差异。
在非公平锁下线程在获取锁时,会先尝试是否能获取资源,在进入等待队列之前再会和头节点唤醒的后继节点来进行一次竞争获取锁,如果失败,那么就会进入到等待队列,当它进入到队列之后,因为队列的有序性,所以其实队列里面的线程想要获取锁都是要乖乖排队了。
因此这里的公平与否,针对的其实是苏醒线程与还未加入同步队列的线程,而对于已经在同步队列中阻塞的线程而言,它们内部自身其实是公平的,因为它们是按顺序被唤醒的,这是根据AQS节点唤醒机制和同步队列的FIFO特性决定的。