Java进阶线程

一:常见的锁策略

(1)乐观锁 vs 悲观锁

1.乐观锁

💗①概念:预测该场景中,不太会出现锁竞争/锁冲突的情况

(锁竞争/锁冲突:两个线程争夺一把锁,其中一个获得锁,另一个阻塞等待)


💛②后续工作:因为锁竞争/锁冲突概率小,导致后续做的工作更少

2.悲观锁

💗①概念:预测该场景中,很大可能会出现锁竞争/锁冲突的情况

(锁竞争/锁冲突:两个线程争夺一把锁,其中一个获得锁,另一个阻塞等待)


💛②后续工作:因为锁竞争/锁冲突概率大,导致后续做的工作更多

3.关于synchronized

💚Synchronized 初始使用乐观锁策略

💚当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略

(2)重量级锁 vs 轻量级锁

1.重量级锁

💜概念:加锁的开销较大,花的时间更多,占用系统资源更多

⭐(一个悲观锁,很有可能是重量级锁)

2.轻量级锁

💜概念:加锁的开销较小,花的时间更少,占用系统资源更少

⭐(一个乐观锁,很有可能是轻量级锁)

3.乐观悲观与重量轻量

①悲观锁的锁冲突概率大,后续工作可能更多,因此开销大,也可以称为重量级锁

②乐观锁的锁冲突概率小,后续工作可能更少,因此开销小,也可以称为轻量级锁


③悲观乐观,是在加锁之前,对锁冲突的预测,然后决定工作的多少

④重量轻量,是在加锁之后,考量实际锁的开销

4.关于synchronized

💚synchronized 开始是一个轻量级锁

💚如果锁冲突比较严重,就会变成重量级锁

(3)自旋锁 vs 挂起等待锁

1.自旋锁

💙概念:自旋锁是一种典型的轻量级锁的实现方式(通过while循环之类的自旋)


如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止!

第一次获取锁失败, 第二次的尝试会在极短的时间内到来!

 一旦锁被其他线程释放, 就能第一时间获取到锁!


优点:没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁


缺点:如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源

(而挂起等待的时候是不消耗 CPU 的)

2.挂起等待锁

💙概念:挂起等待锁是一种典型的重量级锁的实现方式


通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程出现挂起、阻塞等待!


优点:当锁被占用了,不再管,直接放弃 CPU,消耗的CPU资源少


缺点:一旦锁被释放,无法第一时间获取到锁

3.举例理解两个锁的区别

 4.关于synchronized

💚synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的

(4)公平锁 vs 非公平锁

1.公平锁

💗概念:遵守 "先来后到"的锁

2.非公平锁

💗概念:不遵守 "先来后到"的锁

3.举例理解两个锁的区别

假设三个线程 A, B, C

A 先尝试获取锁, 获取成功

然后 B 再尝试获取锁, 获取失败, 阻塞等待

然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待


公平锁:B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁!


非公平锁:B 和 C 都有可能获得锁!

4.关于synchronized

💚synchronized 是非公平锁

5.关于操作系统锁的公平与不公平

操作系统内部的线程调度就可以视为是随机的

如果不做任何额外的限制, 操作系统自带的锁(pthread_mutex)就是非公平锁

如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序


公平锁和非公平锁没有好坏之分, 关键还是看适用场景

(5)可重入锁 vs 不可重入锁

1.可重入锁

同一个线程针对同一把锁连续加锁多次,没有产生死锁现象


可重入锁,可以让锁保存是哪个线程加上的锁,后续再次遇到加锁请求时,就可以先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候可以灵活判断

2.不可重入锁

同一个线程针对同一把锁连续加锁多次,产生死锁现象


不可重入锁,这把锁不会保存是哪个线程加上的锁,只要它当前处于加锁状态之后,后续如果再次遇到加锁请求时,就会拒绝当前加锁操作,而不管这个线程到底是谁,就会产生死锁

3.关于synchronized

💚synchronized 是可重入锁


💓可重入锁的实现方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数)

如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增

每遇到一个加锁操作就加1,遇到一个解锁操作就减1,当计数器减为0时就释放锁

(6)读写锁

1.概念

读写锁就是把读操作和写操作区分对待

也就是读操作加锁和写操作加锁分开了

针对不同的读写加锁方式产生不一样的情况


💛实际开发中,读操作会比写操作用到的频率更高一些

💜因此读写锁最主要用在 "频繁读, 不频繁写" 的场景中

2.分类

①两个线程,读加锁和读加锁之间, 不互斥

💚不会发生锁竞争,此时并发执行效率高

(因为两个线程读取同一个数据不会发生线程安全问题)


②两个线程,写加锁和写加锁之间, 互斥

💚发生锁竞争,此时并发执行效率低

(因为两个线程修改同一个数据会发生线程安全问题)


③两个线程,读加锁和写加锁之间, 互斥

💚发生锁竞争,此时并发执行效率低

(因为两个线程一个线程读另外一个线程写会发生线程安全问题)


💔注:只要是涉及到 "互斥"/锁竞争, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了. 因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径

3.Java标准库的读写锁

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁


🌟读锁:ReentrantReadWriteLock.ReadLock

💓这个对象提供了 lock / unlock 方法进行 加锁解锁


🌟写锁:ReentrantReadWriteLock.WriteLock

💓这个对象提供了 lock / unlock 方法进行 加锁解锁

4.关于synchronized

💚synchronized不是一个读写锁

二:死锁

(1)概念

多个线程同时被阻塞,其中一个或全部线程都在等待某个资源,由于资源争夺而造成的僵局;若无外力推进,他们都将无法推进;由于无限期的阻塞,程序没有办法进行正常终止


💚死锁是一种严重的BUG!! 导致一个程序的线程 "卡死", 无法正常工作

(2)典型案例

①案例一

一个线程一把锁,但是这个锁是不可重入锁,该线程对锁连续加锁两次,就会产生死锁

②案例二

两个线程两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁


🌟例如:GG和Bond两个人去吃饭,桌上有辣椒酱和醋

此时GG拿起了辣椒酱,Bond拿起了醋

GG说:你先把醋给我用,我用完了把辣椒给你

Bond说:你先把辣椒给我用,我用完了把醋给你


💙如果两个人互不相让,就会形成死锁

💙这里的辣椒和醋就相当于是两把锁

💙此时的GGBond都在等待资源,但是由于谁都不肯放手,就造成了双方停滞不前

🌟观察下述代码:

通过运行结果可以看出,什么都没有打印,说明t1和t2都没有成功获取到两把锁,而且t1和t2都是卡在了获取第二把锁上!

主要原因在于t1线程在得到Locker1锁之后又想去获取Locker2锁,但此时Locker2锁已经被t2线程获取到了,必须得等到t2释放Locker2锁,但要想Locker2锁被释放,就得执行完t2线程,可是执行完t2线程又需要获取Locker1的锁;相同情况,t2得到Locker2锁之后又想去获取Locker1锁,这就类似于上述的辣椒酱和醋,因为这种情况,谁都没办法往下执行,就造成了死锁现象!

public class Demo30 {
        private static Object Locker1 = new Object();
        private static Object Locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
            synchronized (Locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (Locker2){
                    System.out.println("t1两把锁加锁成功!");
                }
            }
        });

        Thread t2 = new Thread(() ->{
            synchronized (Locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (Locker1){
                    System.out.println("t2两把锁加锁成功!");
                }
            }
        });

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

    }
}

③案例三

💝N个线程M把锁的时候,我们会通过(“哲学家问题”)理解死锁

💝在死锁中,我们讨论的最多的也是(“哲学家问题”)


💚(“哲学家问题”):

有一个桌子,围着5个哲学家

桌子上放着一份菜

每个哲学家两两之间, 放着一根筷子,注意是1根不是1双

(5个哲学家就是5个线程;5根筷子就是5把锁)


每个哲学家只做两件事: 思考人生 或者 吃面条

思考人生的时候就会放下筷子

吃面条就会拿起左右两边的筷子(先拿起左边, 再拿起右边)
如果哲学家发现筷子拿不起来了(被占用了),就会阻塞等待,等到必须得吃上为止


 🌟特殊情况:假设同一时刻, 五个哲学家同时拿起左手边的筷子,然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了。由于哲学家们互不相让,这个时候就形成了死锁

(3)死锁产生的四个必要条件

①互斥使用即当锁被一个线程使用(占有)时,别的线程不能使用

(锁的基本特性)


②不可抢占:锁只能由当前持有者主动释放,而不能被其他线程直接抢走

(锁的基本特性)


③请求和保持:当一个线程去获取多把锁的时候,在获取其他锁时都保持对原有锁的占有

(取决于代码结构)


④循环等待:即存在一个等待队列,比如P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路

(取决于代码结构)


🌟当上述四个条件都成立的时候,便形成死锁

🌟当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失


💜其中最容易破坏的就是 "循环等待"

(因为①和②是锁的基本特性,改变③的话容易影响需求,有时候还是需要对原有锁的占有)

(4)解决死锁问题

①银行家算法

②破坏循环等待(重点)

💖锁排序:

💜(1)假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3...M)

💙(2)N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁

💛(3)多个线程之间约定好如果要获取多把锁,必须先获取编号小的锁再获取编号大的锁


⭐解决上述哲学家问题的关键就是给每根筷子编号

约定让哲学家先拿编号小的,再拿编号大的;而不是按照左右手来拿,就可以避免死锁


⭐相同,上述的代码一样可以解决问题,也就是将Locker编号小的先执行,只需要将t2的Locker1和Locker2互换顺序,这个时候,t1和t2都按照由小到大的规定顺序执行,就不会发生死锁

 

三:synchronized深度理解

(1)synchronized的基本特点

①synchronized既是悲观锁,也是乐观锁(自适应)

②synchronized既是重量级锁,也是轻量级锁(自适应)

③synchronized重量级锁大部分是基于系统的互斥实现的

④synchronized轻量级锁大部分是基于自旋锁实现的

⑤synchronized是非公平锁(不会遵守先来后到,锁释放后哪个线程拿到各凭本事)

⑥synchronized是可重入锁(内部有个计数器,会记录哪个线程拿到锁)

⑦synchronized不是读写锁

(2)synchronized内部实现原理

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁

根据情况,进行依次升级

也称之为“自适应”


无锁➔偏向锁➔轻量级锁➔重量级锁

1.偏向锁

①概念:并非真的加锁,而是先做了一个“标记”,如果有别的锁来竞争该锁,再取消偏向锁状态, 进入轻量级锁状态;如果没有别的锁来竞争,就自始至终都不会真的加锁!

偏向锁本质上相当于 "延迟加锁"!

(原因在于加锁本身有一定的开销,能不加就尽量不加,尽量来避免不必要的加锁开销)


②举例:假设男主是一个锁, 女主是一个线程

如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去

但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作 完成了, 让女配死心.

2.轻量级锁

①概念:随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)

此处的轻量级锁就是通过 CAS 来实现

通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

如果更新成功, 则认为加锁成功

如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)


②注意:自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源. 因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 "自适应

3.重量级锁

①概念:如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁

此处的重量级锁就是指用到内核提供的 mutex 


②顺序:

1.执行加锁操作, 先进入内核态

2.在内核态判定当前锁是否已经被占用,如果该锁没有占用,则加锁成功,并切换回用户态

3.如果该锁被占用,则加锁失败。此时线程进入锁的等待队列,挂起,等待被操作系统唤醒

4.经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

(3)锁的优化

1.锁消除

概念:编译器会智能的判定当前这个代码是否有必要真的加锁,如果你写了加锁但是实际上并不需要加锁,就会自动把锁操作自动删除掉!

2.锁粗化

概念:实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁;但是实际上可能并没有其他线程来抢占这个锁。 这种情况 JVM 就会自动把锁粗化,避免频繁申请释 放锁

①锁的粒度:如果加锁操作中实际包含要操作的代码越多,就认为锁的粒度越粗


 ②使用细粒度锁,是期望释放锁的时候其他线程能使用锁

💙(细粒度包含要执行的代码少,它很难一次性执行完全部任务)


使用粗粒度锁,可以避免频繁申请释放锁,因为加锁解锁本身也有开销

💙(粗粒度包含要执行的代码多,它就可以一次性执行完全部任务)


举例:

假如你是老板,你给下属安排任务

细粒度锁

 ②粗粒度锁

💘 很明显,方式二更高效吧!!!

四:CAS

(1)什么是 CAS

CAS: Compare and swap

字面意思:”比较并交换“

(相当于通过一个原子的操作, 同时完成 "读取内存, 比较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑)

(有两个寄存器,先比较寄存器1中的值和内存中的值,看是否相等,如果相等就把寄存器2中的值和内存进行交换)


 💚一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B

(V是内存值/A和B都是寄存器中的值)

①比较 A 与 V 是否相等

(比较,看一下内存值value是不是被更改过)

②如果比较相等,将 B 写入 V

(交换)

③返回操作是否成功



💙当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号


💙CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

(2)CAS应用

1.实现原子类

💘标准库中提供了java.util.concurrent.atomic包,里面有很多原子类


💚这里的原子类全部都基于CAS的方式实现

💙最典型的就是AtomicInteger和AtomicLong

💙它们提供了自增/自减/自增任意值/自减任意值这些操作,而这些操作都是基于CAS按照无锁编程来实现的(可以通过无锁的方式来进行变量自增自减而不会出现线程安全问题)

 


💛针对之前的count++我们用原子类来实现看看效果:

import java.util.concurrent.atomic.AtomicInteger;

public class Demo31 {
    private static AtomicInteger count = new AtomicInteger(0);    //初始化count=0

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();      //相当于count++
                //count.incrementAndGet();    //相当于++count
                //count.getAndDecrement();    //相当于count--
                //count.decrementAndGet();    //相当于--count
            }
        });

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

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

        t1.join();
        t2.join();
        System.out.println("count="+count.get());
    }
}


💜CAS是如何实现getAndIncrement()的呢?

①先用oldvalue保存一下原始的value值,然后我们内部使用CAS函数进行操作,根据上面提到过的, value就是的内存值,oldvalue和oldvalue+1都是寄存器中的值

②CAS函数中,首先判断内存值value是不是和寄存器中的oldvalue相等,如果相等说明中途没有被改变过,就把内存值value替换成oldvalue+1,也就相当于实现一个自增过程,若当前这样一个自增完成了,CAS就会返回true,进一步的在while循环条件里就会true != true条件不成立从而跳出循环

③当然,内存值value也会有和寄存器中的oldvalue不相等的情况,因为现在是多线程,很有可能有别的线程将value值修改了,此时CAS就立即返回false,而while循环条件里false!=true条件成立,就进入循环,取出最新的内存值value然后赋值给oldvalue从而更新oldvalue的值,接着进行下一次的比较和交换,同样也可以达到自增

④总结:

①比较value 与 oldvalue是否相等

(比较,看一下内存值value是不是被更改过,相等则没被改;反之就是被改过,改过就更新,重新读取内存的值,准备下一次CAS判定)

②如果比较相等,将 oldvalue写入value

(交换)

③返回操作是否成功

💚区分一下加锁保证线程安全和CAS保证线程安全

①加锁保证线程安全:通过锁的特性阻塞等待,强制避免出现其他线程突然插入修改值的现象

②CAS保证线程安全:借助CAS来判断当前是否出现其他线程突然插入修改,如果没有出现则直接修改,此时是安全的;如果出现了其他线程突然插入修改,就重新读取内存最新值,更新后进行下一次判断尝试修改

2.实现自旋锁

①定义一个owner来表示当前是哪个线程持有这把锁,如果owner=null表示是解锁状态

②Thread.currentThread()表示获取当前线程引用,哪个线程调用lock得到的就是哪个线程引用

③接下来在while循环进行判断,在CAS的内部,判断当前owner是否为null

1.如果是null,就将owner指向当前调用lock的线程,然后返回true,因为是一个while循环条件是一个逻辑取反,此时while整体条件是false,就跳出循环

2.如果不是null,证明该锁目前处于加锁状态,CAS不进行任何操作,直接返回false,逻辑取反后此时while整体条件是true,继续下一轮循环,也就是不断获取锁,重复执行,直到获取锁为止


(3)CAS的ABA问题

1.什么是ABA问题

①前情提要:前面我们提到过CAS的功能,但是重点是在于通过寄存器1和内存值的比较看是否相等,然后判断内存的值是否发生了改变;如果内存的值变了就说明存在其他线程进行了修改;如果内存的值没有改变,就证明没有其他线程修改,此时就是安全的


②ABA问题:问题就在于,内存的值没有变是否就真的证明没有其他线程进行了修改。

假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A

接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要两步

①先读取 num 的值, 记录到 oldNum 变量中

②使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A

此时t1线程并未能察觉,因为这个值虽然变化过成B,但是又变了回A

2.ABA 问题引来的 BUG

大部分情况下,就算出现ABA问题,也不会有很大影响;当然也有一些特例

举例说明:

假设GGBond有 100 存款

GGBond想从 ATM 取 50 块钱

取款机创建了两个线程, 并发的来执行 -50 操作

我们期望一个线程执行 -50 成功, 另一个线程 -50 失败

如果使用 CAS 的方式来完成这个扣款过程就可能出现问题


正常的过程:

①存款目前有100,线程1获取到当前存款值为100,期望更新为50

线程2获取到当前存款值为100,期望更新为50

②线程1执行扣款成功,存款被改成50

线程2阻塞等待中

③轮到线程2执行了,发现当前存款为50,和之前读到的100不相同,执行失败


异常的过程:

①存款目前有100,线程1获取到当前存款值为100,期望更新为50

线程2获取到当前存款值为100,期望更新为50

②线程1执行扣款成功,存款被改成50

线程2阻塞等待中

③在线程2执行之前,GGBond的朋友正好给GGBond转账 50,账户余额变成 100 !!

④轮到线程2执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作

这个时候, 扣款操作被执行了两次!!!

3.解决ABA问题

💖方法:给要修改的值, 引入版本号

(CAS 操作在读取旧值的同时, 也要读取版本号)

(约定如果修改了值,版本号就+1)


💛如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1

💚如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)


通过版本号就可以解决上述ABA的问题

五:JUC(java.util.concurrent) 的常见类

(1)Callable 接口

💜Callable 是一个 interface泛型类;相当于把线程封装了一个 "返回值",重点在于结果

💜Callable同样可以创建线程,里面重写的call方法就可以去执行任务完成计算

💜Callable一般用于线程的计算,例如用多线程的方式计算一条公式之类的


💘Callable 通常需要搭配 FutureTask 来使用

(Callable不能直接作为Thread构造方法参数)

(Callable在call方法写完任务后需要用FutureTask保存,将FutureTask作为Thread构造方法的参数,当启动线程后,此时线程就会执行 FutureTask 内部的 Callable 的call方法完成计算)


💘FutureTask 用来保存 Callable 的返回结果

💚原因:因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定;FutureTask 就可以负责这个等待结果出来的工作


💙理解FutureTask:想象去吃麻辣烫,当餐点好后,后厨就开始做了。 同时前台会给你一张 "小票",这个小票就是 FutureTask。后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没!

🌟使用Callable创建线程计算 1 + 2 + 3 + ... + 100

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;

public class Demo32 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //创建一个匿名内部类, 实现 Callable 接口
        // Callable带有泛型参数. 泛型参数表示返回值的类型,我们这里写Integer
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            //重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        //把 callable 实例使用 FutureTask 包装一下
        FutureTask<Integer> futureTask = new FutureTask<>(callable);

        //创建线程, 线程的构造方法传入 FutureTask
        // 此时新线程t就会执行 FutureTask 内部的 Callable 的call方法完成计算
        // 计算结果就放到了 FutureTask 对象中
        Thread t = new Thread(futureTask);
        t.start();

        //在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕
        //并获取到 FutureTask 中的结果
        Integer result = futureTask.get();

        System.out.println(result);
    }
}

💕Runnable和Callable的比较

①Callable 和 Runnable 相对, 都是描述一个 "任务"

②Runnable 描述的是不带返回值的任务,重在过程,返回void

③Callable 描述的是带有返回值的任务,重在结果,返回一个具体的值

(2)ReentrantLock

1.概念

💛可重入锁,和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全

2.方法

①lock(): 加锁, 如果获取不到锁就死等

②trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁

③unlock(): 解锁


💚unlock会有很多缺陷和漏洞,万一有个return什么的就容易让锁无法释放,因此try-catch-finally是一个很好的解决办法,把unlock放finally里面,因为无论执行到哪,finally都肯定会被执行到


3.ReentrantLock 和 synchronized 的区别

①synchronized是一个关键字,是 JVM 内部实现的(大概率是基于 C++ 实现)

 ReentrantLock是标准库的一个类, 在 JVM 外实现的(基于 Java 实现)


②synchronized 使用时不需要手动释放锁

ReentrantLock 使用时需要手动释放;使用起来更灵活,但是也容易遗漏 unlock


③synchronized在申请锁失败时,会死等

 ReentrantLock可以通过 trylock 的方式等待一段时间就放弃


④synchronized 是非公平锁

ReentrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式


⑤synchronized锁对象是任意对象

ReentrantLock锁对象就是自己本身


⑥synchronized是通过Object 的wait / notify,实现等待-唤醒每次唤醒的是一个随机等待的线程

ReentrantLock搭配Condition类实现等待-唤醒,可以更精确控制唤醒某个指定的线程,拥有更强大的唤醒机制


(3)原子类

1.概念和方法

原子类内部用的是 CAS 实现

性能要比加锁实现 i++ 高很多


💙使用原子类就可以实现无锁编程,也即是说,像i++这种在多线程里容易出现线程安全问题的解决办法不只是加锁,使用原子类也一样可以解决问题



🌟上述的CAS应用已经重点讲过原子类了,这里不再赘述!

2.应用场景

①计数需求

②统计数据

(4)线程池

🌟详情见文章:Java线程③-多线程案例


🌟线程池里的ExecutorService、Executors和ThreadPoolExecutor都属于JUC包

(5)信号量 Semaphore

1.概念

💖表示 "可用资源的个数",描述当前这个线程是否还有“临界资源可用”

(临界资源指的就是多个线程、进程等并发执行的实体可以使用到的公共资源)


💚本质上就是一个计数器


💙它可以允许n个任务同时访问某个资源,并且通过自旋+CAS来保证修改许可值的线程安全性,所以它是一个原子操作,可以同时访问同一个变量

2.P操作和V操作

①P操作:表示申请一个可用资源,此时计数器-1    (用acquire表示)

②V操作:表示释放一个可用资源,此时计数器+1   (用release表示)


💗注意:当计数器为0时,继续进行P操作,就会阻塞等待;等到一直有线程执行V操作,释放一个资源为止!

3.举例理解信号量

 4.Semaphore的方法

acquire 方法表示申请资源(P操作)

release 方法表示释放资源(V操作)


Semaphore的构造方法初始化表示有多少个可用资源

如图所示,初始化为 4, 表示有 4 个可用资源

5.代码理解

​
import java.util.concurrent.Semaphore;

// 信号量
public class Demo33 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 就可以用来指定计数器的初始值.
        Semaphore semaphore = new Semaphore(4);   //4个可用资源
        
        semaphore.acquire(); // 技术器 4-1
        System.out.println("执行 P 操作");  //3
        
        semaphore.acquire(); // 技术器 3-1
        System.out.println("执行 P 操作");  //2
       
        semaphore.acquire(); // 技术器 2-1
        System.out.println("执行 P 操作");  //1
        
        semaphore.acquire(); // 技术器 1-1
        System.out.println("执行 P 操作");  //0
       
        semaphore.acquire(); // 技术器 -1
        System.out.println("执行 P 操作");  //阻塞等待,此时计数器已经为0,不会打印
    }
}

​

(6)CountDownLatch

1.作用

同时等待 N 个任务执行结束

(只能用于一些特定场景)


💘特定场景:下载某个比较大的文件,我们会把这个大文件分为几个小文件下载,使用多个线程分别下载,可以大幅提高下载速度,那么假设我们分成10个小文件也就是10个线程,当10个线程都下载完这个整体才算下载完成,那么我们该如何衡量这10个任务都下载完了呢?这个时候就用到了CountDownLatch!!!

2.方法

CountDownLatch的构造方法初始化表示有多少个任务需要完成

如图所示,初始化为 10, 表示有10 个任务需要完成


countDown();

💙每个任务执行完毕时都调用countDown(),此时在CountDownLatch内部的计数器同时自减

当计数器减为0时,任务全部执行完毕


await();

💙阻塞等待所有任务执行完毕;相当于计数器此时为0


💚将countDown()想象成终点线,10个选手进行跑步比赛,10个选手都跑过终点线才会公布成绩,这个跑步比赛才算结束;也就是相当于10个任务每个任务执行完就调用一下countDown(),每次调用完CountDownLatch内部的计数器减1,直至减为0任务全部完成!

3.代码理解

import java.util.concurrent.CountDownLatch;

public class Demo34 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 指定创建几个任务.
        // 这里我们指定10个任务
        // CountDownLatch里面有个计数器,此时计数器=10
        CountDownLatch countDownLatch = new CountDownLatch(10);
        
        //for循环创建10个线程
        for (int i = 0; i < 10; i++) {
            //这里涉及到lambda表达式的捕获变量,要求捕获到的值时final的,但是这个i很明显在变化
            //我们设置一个变量id,这里的id是不变的,变的是i而已
            int id = i;    
            Thread t = new Thread(() -> {
                //System.out.println("线程" + i + "开始工作!");   //直接写i是会报错的
                System.out.println("线程" + id + "开始工作!");
                try {
                    // 使用 sleep 代指某些耗时操作, 比如下载.
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程" + id + "结束工作!");
                // 每个任务执行结束这里, 调用一下countDown方法
                // 把 10 个线程想象成跑步比赛的 10 个运动员. countDown 就是运动员过终点线了.
                // 每个运动员跑完都需要过终点线,也就是每次任务执行完都调用countDown方法一下,代表单个任务完成
                // 每次调用countDown()计数器-1
                countDownLatch.countDown();
            });
            t.start();
        }

        
        // a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
        // 直到所有任务执行完毕
        countDownLatch.await();

        //此时计数器=0
        System.out.println("多个线程的所有任务都执行完毕了!!");
    }
}

六:线程安全的集合类

(1)安全的集合类

💚原来的集合类,大部分都不是线程安全的

(以前我们使用的都是单线程,因此不必担心是否会出现线程安全问题;但现在是多线程,使用集合类的时候也需要判断,多个线程使用这个集合类是否会出现线程安全问题)


💙Vector, Stack, HashTable,是线程安全的,但我们不建议使用

安全的原因在于这三个在方法中添加了synchronized

(2)多线程环境使用 ArrayList

①自己使用同步机制 (synchronized 或者 ReentrantLock)


②Collections.synchronizedList(new ArrayList);

ArrayList本身没有用synchronized,但是你又不想自己加锁,就可以把ArrayList放入这里


③CopyOnWriteArrayList

称为“写时复制”,这个操作本质上就是一个引用的重新赋值,速度快且是原子的

当有线程去修改时,不是直接修改,而是先把ArrayList自身复制一份,这时候就有一个旧的ArrayList和一个新的ArrayList,此时就会在新的ArrayList进行修改,若这个修改操作耗时比较久,其他线程还是从旧的ArrayList读取数据,一旦修改完成,就会用新的ArrayList去替换旧的ArrayList

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值