多线程进阶

进阶的内容,就关于线程的面试题为主了,涉及到的内容在工作中使用较少,但面试会考!!!

锁的策略

加锁的过程中,在处理冲突的过程中,涉及到的一些不同的处理方法,此处的锁策略,并非是 Java 独有的,需要重点理解一些相关的概念。

1. 乐观锁 和 悲观锁

这是两种不同的锁的实现方式

乐观锁: 在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候,就不会做太多的工作。加锁过程中做的事情比较少,加锁的速度可能就更快,但是更容易引入一些其他的问题。(可能会消耗更多的 CPU 资源)

**悲观锁:**在加锁之前,预估当前出现锁冲突的概率比较大,因此在进行加锁的时候,做的事情就会更多,加锁的速度可能更慢,但是整个过程中不容易出现其他的问题。

2. 轻量级锁 和 重量级锁

轻量级锁:加锁的开销小,加锁的速度更快 ==》 轻量级锁,一般就是乐观锁

重量级锁:加锁的开销大,加锁的速度更慢 ==》 重量级锁,一般即是悲观锁

轻量级和重量级锁:是加锁之后,对结果的一种评价

乐观锁和悲观锁:是在加锁之前,对未发生的事情进行的一种评估

但整体来说,这两种角度,描述的是同一件事情

3. 自旋锁 和 挂起等待锁

**自旋锁:**是轻量级锁的一种典型实现。进行加锁的时候,搭配一个 while 循环,如果加锁成功,自然循环结束。如果加锁不成功,不是阻塞放弃 CPU,而是进行下一次循环,再次尝试获取到锁。

这个反复快速执行的过程,就称为"自旋",一旦其他线程释放了锁,就能第一时间拿到锁,同时,这样的自旋锁,也是乐观锁,**使用自旋锁的前提:就是预期锁冲突的概率不大,其他线程释放了锁,就能第一时间拿到锁。**万一当前加锁的线程特别多,自旋的意义就不大了,白白浪费 CPU 了

挂起等待锁:就是重量级锁的一种典型实现,同时也是一个悲观锁,在进行挂起等待的时候,就需要内核调度器介入了,这一块要完成的操作就很多了,真正获取到锁要花费的时间也就多了。但这个锁是可以适应于锁冲突激烈的情况。

举个栗子:我是一个资深舔狗,每天都会向女神问候:早安午安晚安。有一天,我向女神表白,"女神女神,你能不能做我的女朋友"(尝试加锁),女神给了我一个字:"滚"。

被女神拒绝之后,我有两种处理方式:

  1. 放弃了~~ 从此再也不联系女神了 ==》 我不想联系女神了,进入了阻塞等待,我就把 CPU 让出来了,可以安心学习了(但嘴上说,再也不联系了,身体上还是很诚实。)某一天,我通过其他途径,听说女神分手了,我的心思又活泛起来了,情不自禁又来找女神了,又尝试对女神加锁~~ (如果女神确实分手了,我是有可能上位的)

但注意:这种策略中,我们获知女神分手了之后这个消息,一般是会在发生这个事情之后几个月才听说,我再尝试加锁,就不会像自旋锁那么快~~

线程一旦进入阻塞,就需要重新参与系统的调度,什么时候能够再调度上 CPU 就是未知数了~~

但是这种策略的好处是,在阻塞的过程中,把 CPU 的资源让出来了,可以趁机做一点其他的事情~~(即使有很多人都是女神的备胎,也没关系,反正我是阻塞等待,我能趁机去学习,当备胎 2 3 4 5 号他们和女神相处,我依然学我的,他们全分手了,我再去~~)

  1. 坚信一个道理:只要锄头挥的好,没有墙角挖不倒。仍然每天向女神问候早安午安晚安,时不时的再表白一次。这种方式,就是自旋锁。

当然这种情况,一旦女神分手了,我的机会就来了,就有很大的可能性,乘虚而入,一举加上锁~~~

加锁消耗的时间就比较短,这边一释放,我立即就加上锁。但是缺点就是比较消耗 CPU,每天都得花时间和女神交流(导致我这边就没有心思干别的事情)

自旋锁也是乐观锁,预估了锁竞争不太激烈才能使用,试想一下,如果女神的备胎不止我一个,有十几个兄弟都是备胎,也和我一样每天早安午安晚安一样问候,此时,女神就算分手了,也不一定轮得到我~~

Java 中的 synchronized 呢

Java 中的 synchronized 算那种情况呢? ==》 synchronized 具有自适应的能力!!!

synchronized 在某些情况下,乐观锁 / 轻量级锁 / 自旋锁,在某些情况下,悲观锁 / 重量级锁 / 挂起等待锁

synchronized 的内部会自动的评估当前锁冲突的激烈程度。

如果当前锁冲突的激烈程度不大,就处于 乐观锁 / 轻量级锁 / 自旋锁

如果当前锁冲突的激烈程度很大,就处于 悲观锁 / 重量级锁 / 挂起等待锁

上面 synchronized 会自适应的本质就是 JVM 的大佬们(他暖,我哭),为了让我们这些菜鸟程序员轻松一些,引入的一些优化方式,我们其实并不需要知道这几个锁策略具体是啥意思,就无脑用 synchronized 一般就不会有什么问题,并且还很高效~~~

4. 普通互斥锁 和 读写锁

普通读写锁:就类似于 synchronized 操作会涉及到 加锁 和 解锁

读写锁: 这里的读写锁,就把加锁分为两种情况了

  1. 加"读"锁

  2. 加"写"锁"

读锁和读锁之间,不会出现锁冲突(不会阻塞);写锁和写锁之间,会出现锁冲突(会阻塞);读锁和写锁之间,会出现锁冲突(会阻塞)

一个线程加 读锁 的时候,另一个线程,只能读,不能写

一个线程加 写锁 的时候,另一个线程,不能写,也不能读

为什么要引入读写锁呢?

如果两个线程读,本身就是线程安全的!!!不需要进行互斥!!!

如果使用 synchronized 这种方式加锁,两个线程读,也会产生互斥,产生阻塞...这样的话又没必要,又会对性能产生一定的损失~~

完全给读操作不加锁,也不行,就怕一个线程读操作,一个线程写操作,可能会读到写了一般的数据...

读写锁,就可以很好的解决上述问题~~~ 读写锁就能把这些并发读之间的锁冲突的开销给省下,对于性能的提升十分明显~~~

在标准库中,也提供了专门的类,实现读写锁(本质上还是系统提供的读写锁,提供 API,JVM 中封装了 API 给 Java 程序员使用~~~),这里暂不介绍~~~

5. 公平锁 和 非公平锁

和前面提过的"线程饿死"有一点关系

公平锁:遵守"先来后到",谁先来的,谁就在锁被释放后先获得

非公平锁:不遵守"先来后到"

举个栗子:

当女神和男票恋爱中,兄弟们都在当备胎等待,A 兄弟已经追女神 1 年,B 兄弟追女神 1 个月,C 兄弟昨晚上才开始追女神

当女神分手后:公平锁的情况下,A 号大兄弟是最开始舔的,他就嗖嗖的上位追女神了,剩下两位老哥就继续等着

非公平锁:三位大兄弟不管谁先开始舔的,对着女神就是一拥而上~~~

注意:

操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平的。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序

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

6. 可重入锁 和 不可重入锁

可重入锁 :"可以重新进入的锁",即允许同一个线程多次获取到同一把锁,且不会死锁。 比如一个递归函数中里面又加锁操作,递归的过程中,这个锁如果不会阻塞自己,那么这个锁就是可重入锁 (因此,可重入锁也叫做递归锁)。可重入锁中需要记录持有锁的线程是谁,加锁的次数的计数

不可重入锁:一个线程针对一把锁,连续加锁两次,会产生死锁。

Java 里只要一 Reentrant 开头命名的都是可重入锁,而且 JDK 提供的所有线程的 Lock 实现类,包括 synchronized 都是可重入的锁

理解"把自己锁死":

一个线程没有释放锁,然后又尝试加锁

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥也不相干了,也就无法进行解锁操作,这样就会死锁

上面描述的锁,即为不可重入锁


上面的"锁策略"就是一大堆的名词解释,我们需要对这些词,有一个概念上的认识即可。

例如,对于 synchronized 来说

  1. 乐观锁 / 悲观锁 自适应

  2. 轻量级锁 / 重量级锁 自适应

  3. 自旋锁 / 挂起等待锁 自适应

  4. 不是读写锁

  5. 非公平锁

  6. 可重入锁

对于系统原生的锁(Linux 提供的 mutex 这个锁)

  1. 悲观锁

  2. 重量级锁

  3. 挂起等待锁

  4. 不是读写锁

  5. 非公平锁

  6. 不可重入锁

synchronized 内部的工作原理

synchronized 的内部优化是非常好的,大部分情况下,使用 synchronized 不会有什么问题

但 synchronized 的加锁过程,尤其是"自适应" 的过程,到底是怎么回事呢???

当线程执行到 synchronized 的时候,如果当前这个对象处于未加锁的状态,就会经历以下过程:

1. 偏向锁阶段

核心思想是:懒汉模式,**能不加锁,就不加锁,能晚加锁,就晚加锁。**所谓偏向锁,并非是真的加锁了,而是做了一个非常轻量的标记。

换句话说,就是搞暧昧,就是偏向锁,只是做一个标记,并没有真的加锁(也不会有互斥),但如果发现有其他线程,来和我竞争这个锁,就会在另一个线程之前,先把锁获取到。然后就会从偏向锁升级为轻量级锁(升级为轻量级锁就是真加锁了,存在互斥了~)

如果在搞暧昧的过程中,没有人来竞争,就把加锁这样的操作就完全省略了~~~

非必要 不加锁 在遇到竞争的时候,偏向锁并没有提高效率,但是,如果在没有竞争的情况下,偏向锁也就大幅度的提高了效率~~

举个栗子:

假设我是一个美女(好看 & 有才华 & 琴棋书画样样俱全 & 有心计 & 时间管理大师)

我谈了一个男票,谈了一段时间之后,如果我厌烦了,想换一个男朋友,效率是比较低的,要做两个事情:

  1. 想办法和现在的男朋友分手~~~(各种吵架,没事找事,冷暴力...)

  2. 和下一个小哥哥培养感情

我们前面引入的池的概念就是对第二件事的优化 --> "备胎池"

那怎么对第一阶段进行优化呢?? ==》 搞暧昧~~~

当我最开始和这个小哥哥谈恋爱的时候,我就不和他确认关系~~~

有情侣之实,但是无情侣之名,我们俩每天在一起干的事情,都是情侣之间的事情,但每当小哥哥提到说,我们是什么关系的时候,我就笑而不答,或者扯开话题。

如果未来有一天,我厌倦了,我就直接可以把他拉黑,踹开一边即可,如果他再来纠缠我,我就补充一句,"我们只是朋友~~~"

当前我在和这个小哥哥搞暧昧的时候,如果我突然发现,有其他的妹子,也在试图接近我家小哥哥,这个时候,**我就立即和小哥哥确定关系,**并且让其他妹子,离我家哥哥远点~~~(一切尽在掌握之中,哥哥当然不会拒绝我~)

2. 轻量级锁阶段

(假设有竞争 但不多)

此处是通过自旋锁的方式来实现的。

优势: 另外的线程把锁释放到了,就会第一时间拿到锁

劣势: 比较消耗 CPU 资源

与此同时, synchronized 内部也会统计,当前这个锁对象上,有多少个线程在参与竞争,这里当发现参与竞争的线程比较多了,就会进一步的升级到重量级锁(对于自旋锁来说,如果同一个锁竞争者很多,大量的线程都在自旋,整体 CPU 的消耗就很大了)

补充:偏向锁标记,是锁对象里面的一个属性,每个锁对象都有自己的标记,当这个锁首次被加锁的时候,先进入偏向锁,如果这个过程中,没有涉及到锁竞争,下次加锁还是先进入偏向锁,一旦这个过程中升级成为轻量级锁了,后续再针对这个对象加锁,都是轻量级锁了(跳过了偏向锁~~~)

3. 重量级锁阶段

此时拿不到锁的线程就不会再继续自旋了,而是进入"阻塞等待",让出 CPU(不会让 CPU 的占用率太高)当线程释放锁的时候,就会由系统内核随机唤醒一个线程来获取锁了

到底多少个线程算多呢???这是 JVM 源码里面的,我们要重点关注的是,会有这种"策略",参数是可以随时调整的,策略是通用的!

锁消除

也是 synchronized 中内置的优化策略,是编译器优化的一种方式,编译器在编译代码的时候,如果发现这个代码,不需要加锁,就会自动的把锁给干掉~~~

但这里的优化是比较保守的,比如,就只有一个线程,在这一个线程里加锁了,或者说,加锁代码中,并没有涉及到"成员变量的修改",只是一些局部变量的修改(如果加锁代码块中只涉及局部变量的修改,而没有对成员变量(类的属性)进行修改,也不需要加锁。这是由于局部变量是线程私有的,每个线程都有自己独立的副本,不会出现多个线程同时访问同一个局部变量的情况,也就不会有数据竞争问题。),是不需要加锁的。

其他模棱两可的情况,编译器也不确定的时候,是不会去消除的。

锁消除 ==》 针对 一眼看上去就完全不会涉及到线程安全问题的代码,就能够把锁消除掉

锁粗化

会把多个细粒度 的锁,合并成一个粗粒度的锁

(synchronized { } 大括号里面包含的代码越少,就会认为锁的粒度越细,包含的代码越多,就会认为锁的粒度越粗)

通常情况下,让锁的粒度细一些, 是有利于多个线程并发执行的,但也有点时候,希望锁的粒度粗一些~~~

如上图,在代码执行的过程中,涉及很多加锁和解锁,即锁的粒度较细,每次加锁都是有可能涉及到阻塞的

如下图,编译器就会把三次细锁粒度的锁合并成一个粗粒度的锁了 ==》 粗化也是为了提高效率~~~

小结:

synchronized 背后是涉及了很多很多的"优化手段"

  1. 锁升级 ==》 偏向锁 -> 轻量级锁 -> 重量级锁

  2. 锁消除 ==》 自动干掉不必要的锁

  3. 锁粗化 ==》 把多个细粒度的锁合并成一个粗粒度的锁,减少锁竞争的开销

这些机制都是在内部默默发挥作用的,是 JVM 的大佬为我们默默奉献的(他暖,我哭~~~

CAS

什么是 CAS

CAS:Compare and swap,字面意思:"比较并交换",是一个特殊的 CPU 指令(严格的说,和 Java 无关)(JVM 中 关于 CAS 的 API 都是放在 unsafe 包里的,unsafe 即不安全的)

一个 CAS 就会涉及到一下操作:我们假设内存中的原数据为 V,寄存器中的值是 A,需要修改的是新值 B,会有三个操作:

  1. 比较原数据 V 和寄存器中的值 A 是否相等 (比较)

  2. 如果比较相等,把 B 写入 V。(交换)

  3. 返回操作是否成功

CAS 伪代码

其中,address 是内存地址中的值 expectValue 是寄存器中的旧值,swapValue 是寄存器中的新值。 if 语句中判断条件是,比较 address 内存地址中的值,是否和 expected 寄存器中的值相同,如果相同,就把 swap 寄存器的值和 address 内存中的值,进行交换,返回 ture;如果不相同,则啥都不敢,返回 false。(说是交换,也可以理解为"赋值",我们往往只关注内存里最终的值,寄存器用完就不需要了~~)

CAS 一条 CPU 指令 就可以完成我们上述的功能 ==》单个 CPU 指令,本身就是原子的

CAS 的线程安全问题

基于 CAS 指令,就给线程安全问题的代码,打开了一个新世界的大门!!!我们之前为了实现线程安全,往往都是依靠加锁来保证的,但一旦有了加锁,就会导致阻塞,从而就会引起性能降低。

使用 CAS,不涉及加锁,就不会导致阻塞,合理使用也是可以保证线程安全的 ==》 无所编程(是多线程编程中的一个特殊技巧)

CAS 本身的 CPU 指令,操作系统又对指令进行了封装,JVM 又对操作系统提供的 API 封装了一层,有的 CPU 可能会不支持 CAS (但我们 x86 这种主流 CPU 都是没问题的)

Java 中的 CAS 的 API 放到了 unsafe 包里面(这里面的操作, 涉及到一些系统底层的内容,使用不当的话可能会带来一些风险,一般不建议直接使用 CAS)

Java 的标准库,对于 CAS 又进行了进一步的封装,提供了一些工具类,供程序员们使用。

最主要的一个工具,叫做 "原子类"

在这个类中,就进行了一些封装,比如对 Integer 和 Long进行了封装,针对这样的对象进行多线程修改,就是线程安全的了。

示例代码:

这个代码就是我们之前典型的多线程可能不安全的代码,如果定义 count 的时候,是使用 private static int count = 0,然后在线程中均使用 count++的话,就是线程不安全的!!!

但是,如果是用 AtomicInteger 定义 count(此时是一个对象了),初始值传入参数为 0,然后再线程中,使用 getAndIncrement 方法,代替了 后置++,此时这个方法,就是通过 CAS 的方式实现的,这里的代码,就没有加锁,但也能保证线程的安全!!(并且这个代码要更为高效,没有锁,也就没有阻塞,也就不会损耗效率)

之前的 count++ 是三个指令(多线程的三个指令,会相互的穿插执行,引起线程不安全,之前加锁,就是为了能让三个指令称为原子的)此处,这里的 getAndIncrement 对变量的修改,是 CAS 指令,CAS 指令本身就只是一条 CPU 指令,天然就是原子的

原子类自增的源代码:

标准库中的代码,看起来有点复杂,我们可以用一段伪代码来理解:

再这段伪代码中,oldValue 期望是一个放在寄存器里面的值,这个值就是初始化成 AtomicInteger 里面保存的整数值 value,如果内存地址的值 value 和 寄存器里面的值 oldValue 比较相同,则可以交换,oldValue + 1 和 value 交换,然后循环结束,此时 value 已经更新成 value + 1了,如果没成功,就再来一次,直到成功为止

画图讲解:

如下图为多线程情况下:

最开始我们初始化 value 为 0

多线程执行 ==》

t1 线程,将 value 赋值给 oldValue

然后调度到 t2 线程执行,t2 线程也赋值 oldValue 为 0

然后 t2 线程进入 while 循环,比较 value 和 oldValue 此时均为 0,此时还有一个寄存器三,为 oldValue + 1(即此时为 1)

会将 oldValue + 1 寄存器中的值 1 和 内存中的 0 进行交换

这样就通过线程 2,将 value 从 0 -> 1,将 value 重新赋给 oldValue 返回 oldValue。

然后 t1 线程又被调度上来了,再执行 t1 线程

注意,这个时候,t1 线程中再执行,value 的值已经由 0 变为 1了,但此时寄存器 1 的 oldValue 记录的仍然是 0,这里就会发现 value 和 oldValue 不同,意味着在 CAS 之前,另一个线程修改了 value(通过这个方式,能识别出是否有人修改)所以就不会进行交换,进入while 循环,将 value 的值,重新赋给 oldValue

然后再进入 while 循环,这时候 value 和 oldValue 的值就相同了,然后还有另一个寄存器存储 oldValue + 1

再进行交换,将 value 从 1 -> 2,然后将 value 再赋值给 oldValue 返回 oldValue

之前的线程不安全,是内存变了,但是寄存器中的值没有跟着变,接下来的修改操作就会出错了,但使用 CAS 这种方式,通过一次内存和寄存器值的比较,就能确保识别出内存的值是不是变了,不会,才会进行修改,如果变了,就会重新读取内存的值,确保是基于内存中的最新的值进行修改。非常巧妙的把之前的线程安全问题就解决了~~~

实现自旋锁

基于 CAS 实现更灵活的锁,获得到更多的控制权

自旋锁伪代码:

当 owner 不为 null 的时候,意味着锁已经被其他线程持有。此时,当前尝试获取锁的线程并不会进入阻塞状态(不会像传统锁机制下调用 wait 方法一样阻塞)而是在这个 while 循环中不停的执行("忙等")。持续的尝试 CAS 操作区获取锁,只要获取不成功就一直循环,不放弃 CPU 资源,但也不参与 CPU 调度中的线程上下文切换等调度流程,避免了调度开销~~~但是这种方式的缺点就是自旋的锁会一直占用 CPU,需要消耗更多的 CPU 资源。

CAS 的 ABA 问题

举个栗子:"翻新机",我们以为买到的是一个新的极其,但实际上买到的是一个"二手的机器",外表看起来是崭新的,但是内部已经是别人的形状了...

CAS 在使用的时候,关键要点是:判定当前内存的值是否和寄存器中的值是一样的 ==》 如果是一样的,就进行修改,不一样,就什么也不做。(本质上是判定,但是如果当前代码执行过程中,有其他线程穿插进来了...可能存在这样的情况,比如数值本来是 0,执行 CAS 之前,另一个线程把这个值从 0 -> 100,又从 100 -> 0,虽然最终的结果仍然是 0,但并不是没有别的线程穿插,而是其他线程穿插过程中,把值修改了,又改回去了)。一般来说,即使出现上述的情况,问题也不大,不会产生什么 bug,但是怕是一些极端的场景!!!

假设,去银行取钱~~~

初始情况下,账户余额 1000,要取 500。取钱的时候,ATM 卡了一下,按了一下没有反应(t1线程),又按了一下(t2线程)。此时产生了两个线程,去尝试进行扣款操作了。

如果是按照上述的方法来执行,是可以正常执行的,没有问题。

但如果,就在此时此刻,t3 线程,又给我们的账户,存了 500。此时,唉哟我嘞个豆

t1 线程执行到这里,就不知道,当前的 balance 中的 1000 是个什么情况了,是始终没有变化呢? 还是变了又变回来了...那 t1 线程,如果认为是没有变化,继续减 500,那岂不是我亏大了~~~

上面的 ATM 栗子,充满了假设和巧合,是一个非常极端的栗子

对于 ABA 问题,解决方案:

  1. 约定数据变化是单向的(只能增加或者减少),不能是双向的(既能增加,又能减少)

  2. 对于本身就必须双向变化的数据,可以给它引入一个版本号,版本号这个数字就是只能增加,不能减少的

补充: CAS 的操作,本是上还是 JVM 帮我们封装好的,上面所述的细节我们是没办法直接感知到的~~~

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

JUC 这个包里面,存放了一些进行多线程编程的时候的一些比较有用的类

Callable 接口

先回忆一下,我们之前创建线程的方法:

  1. 继承 Thread(包含匿名内部类的方式)

  2. 实现 Runnable(包含了匿名内部类的方式)

  3. 基于 lambda 表达式

  4. 就是基础 Callable**(interface)**

  5. 基于线程池

Runnable 关注的是过程,不关注执行结果,Runnable 提供的 run 方法,返回值类型是 void,Callable 要关注执行结果,Callable 提供的 call 方法,返回值是线程执行任务得到的结果

如果我们要编写多线程代码,希望关注线程中代码的返回值的时候,创建一个新线程,用新的线程实现 1 + 2 + 3 +.. + 1000

代码实现如下:

虽然上面的代码可以解决问题,但并不**"优雅"(**要在主线程中获取到线程中的计算结果,还要再倒腾一个成员变量来获取)。

使用 Callable 可以更好的解决问题

这个泛型<V>,代表的是,期望线程的入口方法中,返回值的类型

这里我们希望返回值是一个整数 --> Integer

这样使用 Callable,就不需要引入额外的成员变量了,直接借助这里的返回值即可~~

但是,当我们传入的时候发现,Thread 并没有提供构造函数来传入 callable

这里我们可以引入一个 FutureTask类,来作为 Thread 和 callable 的"粘合剂"。

futureTask ==》 未来的任务(任务可能还没执行完呢),既然这个任务是在未来执行完毕,最终去取结果的时候,就需要有一个凭据 ,这个凭借就是 futureTask(举个栗子:吃麻辣烫,选好菜之后,服务员会给我们一个小牌子,小牌子上有号码,到时候拿小牌子取餐~~~这个小牌子,就是 futureTask)。此时代码也不需要 t.join() 了~~~

注意,futureTask.get() ,这个操作也是具有阻塞功能的,如果线程还没执行完毕,get 就会阻塞,等到线程执行完毕了,return 的结果,就会被 get 给返回回来!

Callable 其实是一个 "锦上添花" 的东西,它能干的事情,其实 Runnable 也能干,不过,对于这种带有返回值的任务,在多线程中使用 Callable 的确会更好一些,代码更直观,更简单~~~

不过还是需要重点理解 FutureTask 的作用!!!

完整代码如下:

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;


public class ThreadDemo46 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 0; i < 1000; i++) {
                    result += i;
                }
                return result;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        System.out.println(futureTask.get());
    }
}

ReentrantLock

ReentrantLock:是一种可重入锁,与 synchronized 定位类似,都是用来实现互斥效果,保证线程安全(ReentrantLock 也是可重入锁,"Reentrant"的单词的原意就是"可重入")

synchronized 也是可重入锁呀。上古时期的 Java 中,synchronized 不够强壮,功能也不够强大,也并没有我们上面所述的各种优化,ReentrantLock 就是用来实现可重入锁的选择(历史遗留问题)后来 synchronized 被各种优化的变的厉害了之后,ReentrantLock 就用的少了,但仍然有一席之地~~

ReentrantLock 是传统锁的风格,这个对象提供了两个方法:lock 和 unlock

这种写法,就容易引起,我们加了锁之后,忘记 unlock 解锁了。或者是,在 unlock 之前,触发了 return 或者 异常,就可能 unlock 执行不到了。==》正确使用 ReentrantLock 就需要把 unlock 的操作放到 finally 里面

ReentrantLock 与 synchronized 的区别

既然有了 synchronized(优化也非常好)那为什么还要有 ReentrantLock 呢???

1.ReentrantLock 提供了 tryLock 操作。lock 是直接进行加锁,如果加锁不成功,就会阻塞。但 trylock,是尝试进行加锁,如果加锁不成功,不会阻塞,会直接返回一个 false。(提供了更多的"可操作空间")

  1. ReentrantLock 提供了公平锁的实现(通过队列记录加锁线程的先后顺序)。synchronized 是非公平锁。在 ReentrantLock 构造方法中填写参数,就可以将其设置为公平锁

  2. 搭配的等待通知机制不相同。对于 synchronized,搭配 wait / notify。 对于 ReentrantLock,搭配 Cindition 类,功能比 wait / notify 略强一点点,可以更精确控制唤醒某个指定的线程....

但是,在实际上绝大部分的开发中,使用 synchronized 就足够了!!!

信号量 Semaphore

信号量,用来表示"可用资源的个数",本质上就是一个计数器

举个栗子来理解:

可以把信号量想象成是停车场的展示牌:当前有车位 100 个,表示有 100 个可用资源。当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1(这个称为信号量的 P 操作 ),当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1(这个称为信号量的 V 操作)。如果计数器的值已经为 0 了,该尝试申请资源,就会阻塞等待,知道其他线程释放资源。

Semaphore 的 PV 操作中的加减计数器操作都是原子的,可用在多线程环境下直接使用

信号量也是操作系统内部给我们提供的一个机制,操作系统对应的 API 被 JVM 封装了一下,就可以通过 Java 代码来调用这里的相关操作了~~~

信号量是更广义的锁!!!

所谓的锁,本之上也是一种特殊的信号量。锁,可用认为就是计数值为 1 的信号量。释放状态,就是计数值为 1 的信号量,加锁状态,就是计数值为 0 的信号量。对于这种非 0 即 1 的信号量,称为 "二元信号量"。

代码示例:

作为锁使用:

CountDwonLatch

CountDownLatch 是针对特定场景来解决问题的小工具

比如,多线程执行一个任务,把大的任务,拆分成几个部分,由每个线程分别执行。

举个栗子:"多线程下载",例如 IDM 这样的软件。下载一个文件,这个文件可能很大,但是可用拆成多个部分,每个线程负责下载一部分,下载完成之后,最终把下载的结果都拼接到一起。在多线程下载的场景,最终执行完成之后,要把所有内容拼到一起,这个拼接必须要等到所有的线程执行完毕。

使用 CountDownLatch 就可以很方便的感知到上面的这个事情(所有的线程执行完毕)(比我们调用多次 join 要简单方便一些~~~)

如果使用 join 方式,就只能使每个线程只执行一个任务,借助 CountDownLatch 就可用让一个线程能执行多个任务~~~

示例代码:

线程安全的集合类

原来的集合类,大部分都不是线程安全的。但 Vector,Stack,Hashtable 是线程安全的,这三个类,在关键方法上加上了 synchronized,因此,这几个兄弟,无论如何都得加锁,哪怕单线程的时候,也需要加锁,这样的做法是不科学的,这几个好兄弟,现在官方已经不建议使用了,可能在未来的某个版本就删掉了...

多线程环境使用 ArrayList

1. 程序员自己按照情况使用同步机制(synchronized 或者 ReentrantLock)

前面有讲解,此处不做重复说明~

2. Collections.synchronizedList(new ArrayList)

这个包里面的方法,相当于是给 ArrayList 套了一个壳,ArrayList 本身的各种操作是不带锁的,但是通过上面的套壳操作之后,得到了新的对象,新的对象里面的方法就是都带有锁的,这样更方便我们灵活使用~~~

3. 使用 CopyOnWriteArrayList

这玩意是叫 写时拷贝

线程安全问题,本质上就是多个线程修改同一个数据的时候可能出现问题。

例如有一个顺序表如下:

如果多个线程,读这个程序表,是没有任何线程安全问题的。

但一旦有线程要修改里面的值,就可能引发线程安全问题

但如果使用CopyOnWriteArrayList,它如果发现有线程修改了里面的值,它就会把顺序表复制一份, 修改新的顺序表内容,并且修改引用的指向(这个操作是原子的,不需要加锁)

总结来讲就是:

当我们往一个容器添加元素的时候,不直接往容器里面添加,而是线将当前容器进行 Copy,复制出一个新的容器,然后在新的容器里面添加元素。添加完元素之和,再将原容器的引用指向新的容器。

这样做的好处是,可用对 CopyOnWrite 容器进行并发的读,而且不需要加锁,因为当前容器不会添加任何的元素。所以 CopyOnwrite 容器其实也是一种读和写分离的思想,读和写是不同的容器。

优点: 在读操作多,写操作少的场景下,性能不是很高,不需要加锁竞争

缺点:占用内存较多,并且新写的数据不能被第一时间读到

多线程环境使用哈希表

HashMap 本身不是安全的

在多线程环境下使用哈希表可用使用:Hashtable(在关键方法上添加了 synchronized) 和 ConcurrentHashMap

Hashtable

  1. 只是简单的在关键方法加上了 synchronized 关键字

这相当于直接针对 Hashtable 对象本身加锁

此时,尝试修改两个不同链表的元素,都会触发锁冲突!!!(仔细观察,就会发现,如果修改两个不同链表上的元素,并不会涉及线程安全问题。如果修改的是同一个链表上的元素,才会可能涉及到线程安全问题~~~此时,针对同一个链表,是需要加锁的,如果针对的是不同链表进行操作,是不需要加锁的!!!)

  1. size 属性也是通过 synchronized 来控制线程同步是,也会比较满

  2. 一旦触发扩容,就由该线程完成整个扩容过程,这个过程就会涉及到大量的元素拷贝,效率非常低 ==》 不稳定~~~

ConcurrentHashMap

相比于 Hashtable 进行了一系列的改进和优化

(在 Java 1.7 及其之前,ConcurrentHashMap 是通过"分段锁"来实现的。给若干个链表分配一把锁,这样设定,不太合适,实现也复杂)

Java 1.8 中:

  1. 读操作没有加锁了(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁,加锁的方式仍然是 synchronized,但不是锁整个对象,而是"锁桶"(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率。

ConcurrentHashMap 就是把锁变小了,给每一个链表都发了一个锁

此时,操作不同链表的时候,就不会产生锁冲突。而且上述设定,不会产生更多的空间代价。因为 Java 中任何一个对象都可用直接作为锁对象。本身哈希表中,就得有数组,数组的元素都是已经存在的,此时,只需要使用数组元素(链表头结点)作为加锁的对象即可。

  1. 充分利用 CAS 特性:比如 size 属性通过 CAS 来及逆行更新,避免出现重量级锁的情况。

synchronized 虽然刚开始是偏向锁 / 轻量级锁,但是有可能升级为重量级锁,且过程是不可控的

  1. 针对扩容操作的优化 --> 化整为零

扩容是一个重量操作,这里有一个概念是负载因子,即描述了每个桶上平均有多少个元素,当同上的链表的元素个数不是太多,就能达到 O(1) 时间复杂度

(负载因子不是 0.75!!!0.75 是负载因子默认的扩容阈值,不是负载因子本体。负载因子是我们算出来的数,用实际的元素个数 / 数组的长度,那我们算出来的值和扩容阈值进行比较,来看是否需要扩容)

如果桶上的链表的元素个数太多 ==》 1. 变成树 2. 扩容

扩容,即创建一个更大的数组,把就的 hash 表的元素都给搬运到新的数组上,如果 hash 表本身元素非常多,这里的扩容操作就会消耗很长的时间!!!(hash 表平时都很快,O(1),突然间某个操作非常慢,然后过一会就又快了,这样的表现是不稳定的,无法控制什么时候触发扩容)

ConcurrentHashMap 就优化为了化整为零,蚂蚁搬家~~~

  1. 发现需要扩容的线程,会创建一个新的数组,同时只搬运几个元素过去

  2. 扩容期间,新老数组同时存在。

  3. 后续每个来操作 ConcurrentHashMap 的线程,都会参与搬运的过程,每个操作负责搬运一小部分元素~~~

  4. 搬完最后一个元素,再把老的数组删掉

  5. 这个期间,插入只往新数组中添加

  6. 这个期间,查找需要同时查新数组和老数组~~~

HashMap 的扩容操作是一把梭哈,在某一次插入元素的操作中,整体完成扩容了

ConcurrentHashMap 则是每次操作都只搬运一部分元素

假设这里有 1kw 个元素,此时扩容的时候,每次插入 / 查找 / 删除,都会搬运一部分元素,一共会用多次搬运完成(花的时间会长一些,虽然总体时间变长了,但是每次操作的时间都不会很长,就避免出现很卡的情况了~~~)

完!!!

相关推荐
weifexie20 分钟前
ruby可变参数
开发语言·前端·ruby
王磊鑫21 分钟前
重返JAVA之路-初识JAVA
java·开发语言
千野竹之卫21 分钟前
3D珠宝渲染用什么软件比较好?渲染100邀请码1a12
开发语言·前端·javascript·3d·3dsmax
半兽先生42 分钟前
WebRtc 视频流卡顿黑屏解决方案
java·前端·webrtc
liuluyang5301 小时前
C语言C11支持的结构体嵌套的用法
c语言·开发语言·算法·编译·c11
凌叁儿1 小时前
python保留关键字详解
开发语言·python
南星沐2 小时前
Spring Boot 常用依赖介绍
java·前端·spring boot
明飞19872 小时前
C_内存 内存地址概念
c语言·开发语言
代码不停2 小时前
Java中的异常
java·开发语言
兮兮能吃能睡2 小时前
Python中的eval()函数详解
开发语言·python