【JavaEE】多线程05

1.常见的锁策略

如果想要自己实现一把锁,就需要关注锁策略。

1.1 悲观锁 与 乐观锁

悲观锁与乐观锁不是针对某一种具体的锁,而是某个具体的锁具有 "悲观" 特性或者 "乐观" 特性。

悲观锁:

  • 加锁的时候,预测接下来的锁竞争情况非常激烈,需要针对这样的激烈情况额外做一些工作。
  • 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁, 这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁:

  • 加锁的时候,预测接下来的锁竞争情况不激烈,就不需要做额外的工作。
  • 假设数据⼀般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则返回用户错误的信息,让用户决定如何去做。

示例:同学 A 和 同学 B 想请教老师⼀个问题.

  • 同学 A 认为 "老师是比较忙的,问问题老师不⼀定有空解答",因此同学 A 会先给老师师发消息询问是否有空(相当于加锁操作),得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等⼀段时间, 下次再来和老师师确定时间. 这个是悲观锁。
  • 同学 B 认为 "老师是比较闲的, 问问题老师大概率是有空解答的",因此同学 B 直接就来找老师.(没 加锁, 直接访问资源),如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁。

这两种锁不能说谁优谁劣,要看当前的场景是否合适。

synchronized 即是悲观锁,又是乐观锁,它是自适应的:初始使用的是乐观锁策略,当发现锁竞争比较频繁的时候,会自动切换成悲观锁策略

2.2 重量级锁 与 轻量级锁

悲观锁和乐观锁描述的是++加锁时遇到的场景++ ,而重量级锁和轻量级锁描述的则是++加锁后遇到的场景的解决方案。++

重量级锁,当悲观场景下,付出的代价更多,更低效;轻量级锁,当乐观场景下,付出的代价更小,更高效。

锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

  1. CPU 提供了 "原子操作指令"。
  2. 操作系统基于 CPU 的原子指令, 实现了 mutex(互斥量) 互斥锁。(Java 中的 mutex = 任何能保证同一时间只有一个线程执行某段代码的锁机制,互斥+阻塞等待)
  3. JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。

注意:synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作

重量级锁:加锁机制依赖操作系统提供的 mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度
  • 这两个操作, 成本比较高. ⼀旦涉及到用户态和内核态的切换, 就意味着 "沧海桑⽥".

轻量级锁:加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成,如果最后搞不定,再使用 mutex

  • 少量的内核态用户态切换
  • 不太容易引发线程调度

synchronized 依然自适应:开始是⼀个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。

2.3 挂起等待锁 与 自旋锁

挂起等待锁 是重量级锁的典型实现,是++操作系统内核级别++ 的,加锁的时候发现竞争(锁竞争激烈),就会使该线程进入阻塞状态,后续就需要内核进行唤醒了。获取锁的周期长,很难做到及时获取,但是这个过程不需要一直消耗CPU资源,把CPU省出来做别的事(让出了CPU的调度)。

而 自旋锁 是轻量级锁的典型实现,是++应用程序级别的(用户态级别)++ ,加锁的时候发现竞争(锁竞争不激烈),一般也不是进入阻塞,而是通过忙等的形式来进行等待。获取锁的周期短,及时获取,但是这个过程会一直消耗CPU资源

  • 忙等,是乐观锁的场景,本身遇到的锁竞争概率很小,真的遇到竞争,在短时间内就能拿到锁。

在 JVM 内部,会统计每个锁竞争的激烈程度:

  • 如果竞争不激烈,此时 synchronized 就是一个轻量级锁(自旋锁)
  • 如果竞争不激烈,此时 synchronized 就是一个重量级锁(挂起等待锁)

2.4 普通互斥锁 与 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读取之间都需要进行互斥。如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁,在执行加锁操作时需要额外表明读写意图,读取数据之间并不互斥,而写数据则要求与任何人互斥。

⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据

  • 两个线程都只是读⼀个数据, 此时并没有线程安全问题,直接并发的读取即可
  • 两个线程都要写⼀个数据,有线程安全问题
  • 一个线程读另外⼀个线程写,也有线程安全问题.

读写锁就是把读操作和写操作区分对待. Java 标准库提供了ReentrantReadWriteLock类,实现 了读写锁。

  • ReentrantReadWriteLock.ReadLock 类表示⼀个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁。
  • ReentrantReadWriteLock.WriteLock 类表示⼀个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁。

其中,

  • 读加锁和读加锁之间,不互斥
  • 写加锁和写加锁之间,互斥
  • 读加锁和写加锁之间,互斥

读写锁特别适合于 "频繁读,不频繁写" 的场景中。

synchronized 不是一个读写锁

2.5 可重入锁 与 不可重入锁

可重入锁的字⾯意思是"可以重新进入的锁",即++允许同⼀个线程多次获取同⼀把锁++。

不可重入锁就是,同一个线程多次获取同一把锁,在第二次加锁的时候,会阻塞等待,直到第一次加锁被释放,才能再次获取锁,但是第一次释放锁也是由这个线程来完成的,此时这个线程阻塞在第二次加锁,也无法对第一次加锁进行解锁,这样会造成死锁。

Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。

2.6 公平锁 与 非公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功,然后 B 再尝试获取锁,获取失败, 阻塞等待; 然后 C 也尝试获取锁,C 也获取失败,也阻塞等待,当线程 A 释放锁的时候, 会发生啥呢?

  • 公平锁: 遵守 "先来后到",B ⽐ C 先来,当 A 释放锁的之后,B 就能先于 C 获取到锁。
  • ⾮公平锁: 不遵守 "先来后到",B 和 C 都有可能获取到锁,概率均等。

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

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

synchronized 是非公平锁

2. CAS

CAS: 全称Compare and swap,字⾯意思:"比较并交换" ,一个CAS涉及以下的操作:

  • 假设内存中的原数据V,旧的预期值A,需要修改的新值B。
    1. 比较 A 与 V 是否相等。(比较)
    1. 如果比较相等,将 B 写⼊ V。(交换)
    1. 返回操作是否成功。

CAS的伪代码:下述的代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程:

java 复制代码
boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) {
        &address = swapValue;
        return true;
    }
    return false;
}

CAS,其实是CPU的一条指令,是原子的。操作系统会把这个指令进行封装,提供一些 API,JVM可以调用这样的 API,也就是调用 CAS 这样的操作。

CAS的应用

1)实现原子类

标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的,这个包中,还对 boolean,int,long 等这些类型进行了封装。

使用原子类的目的,就是为了避免加锁。典型的就是 AtomicInteger 类,其中的其中的

  • getAndIncrement() 方法,就相当于 i++ 操作
  • incrementAndGet() 方法,就相当于 ++i 操作
  • decrementAndGet()方法,就相当于 --i 操作
  • getAndDecrement()方法,就相当于 i-- 操作
  • addAndGet(i) 方法,就相当于 += i 操作
  • 这说明,++,-- 等这样的操作,针对内置类型都是非原子的,而针对原子类,则是原子的,基于CAS实现,不涉及加锁操作。

count++ ,这样的操作是线程不安全的,需要通过加锁来保证线程安全,但是又认为加锁的效率比较低,就可以通过 CAS 来实现 count++,确保性能,同时保证线程安全。

如以下的代码,是线程不安全的,实现线程安全而又不需要加锁,可以使用 原子类AtomicInteger 来代替 int

使用原子类:

这样就能保证线程安全:

注意:原子类,是专有名词,特指 atomic 这个包中的类,通过 synchronized 关键字保证一个修改的原子性,和原子类不相关。在多线程修改一个变量的情境中,直接使用原子类比加锁更多。

以上的多线程中使用原子类代替 int 类型的count,那么count++ 是线程安全的,我们可以通过以下的调度图进一步理解:

基于CAS的原子类 AtomicInteger 伪代码实现,其中 oldValue 理解为寄存器,value 是内存:

java 复制代码
class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
        }
        return oldValue;
    }
}

上述代码中,出现线程切换,由于在进行自增之前,先判定当前寄存器读到的值是否是"科学的值",如果不是,就会进行重新读取。另一方面,赋值和判断是原子的,一个指令完成的,无法构造出调度顺序,它只能按照既定的顺序取调度,即无法在赋值和判断中间加入其他的线程逻辑。

2)实现自旋锁

自旋锁伪代码:

java 复制代码
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.  
        // 如果这个锁已经被别的线程持有, 那么就⾃旋等待.  
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.  
        while(!CAS(this.owner, null, Thread.currentThread())){
        
        }
    }
    public void unlock (){
        this.owner = null;
    }
}

CAS的 ABA 问题

使用CAS能够进行线程安全的编程,核心就是先比较相等,内存和寄存器的值是否相等,这里本质是在判断是否有其他线程插入进来,做了一些修改:如果发现这里寄存器和内存的值一样,就可以认为没有线程穿插过来修改,或者修改完又改回去了,因此,接下来的修改操作就是线程安全的。

而上述的修改完再改回去的过程,就是ABA问题。

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 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?

到这⼀步, t1 线程无法区分当前这个变量始终是 A, 还是经历了⼀个变化过程,这就是ABA问题

2)ABA引来的Bug

CAS的ABA问题,其实大部分情况下,即使出现了ABA问题,最终的程序一般问题也不大。只有一些极端的场景,ABA问题才会产生一些严重的Bug。

示例:账户中有1000余额,要取款500。按照 CAS 的方式来执行:

java 复制代码
int oldBalance = balance;
CAS(balance,oldBalance,oldBalance-500);

正常情况下:

异常过程:此时还有第三个线程,在线程1取款500后,转账500给账户,那么此时的账户余额又变回了1000:

3)解决方案:版本号

给要修改的值, 引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。

CAS 操作在读取旧值的同时,也要读取版本号,真正修改的时候:

  • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 +1
  • 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)
java 复制代码
int oldVersion = version;
int oldBalance = balance;
if(CAS(version,oldVersion,oldVersion+1)) {
    CAS(balance,oldBalance,oldBalance-500);
}

在 Java 标准库中提供了AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能。

3.synchronized 原理

结合上⾯的锁策略,我们就可以总结出,synchronized 具有以下特性:

  1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
  2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
  3. 实现轻量级锁的时候大概率用到的自旋锁策略。
  4. 是⼀种不公平锁。
  5. 是⼀种可重入锁。
  6. 不是读写锁。

3.1 加锁工作过程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次的锁升级(自适应)。

  • 1)偏向锁:
    • 第一次尝试加锁的线程,优先进入偏向锁状态
    • 偏向锁不是真的 "加锁", 只是给对象头中做⼀个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁 (刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前 申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进⼊⼀般的轻量级锁状态。
    • 偏向锁本质上相当于"延迟加锁",能不加锁就不加锁, 尽量来避免不必要的加锁开销(懒汉模式的体现)。但是该做的标记还是得做的, 否则⽆法区分何时需要真正加锁。
  • 2)轻量级锁:
    • 随着其他线程进入竞争,偏向锁状态被消除, 进⼊轻量级锁状态(自适应的自旋锁)。此处的轻量级锁就是通过 CAS 来实现.
      • 通过 CAS 检查并更新⼀块内存 (比如 null => 该线程引用)
      • 如果更新成功, 则认为加锁成功
      • 如果更新失败, 则认为锁被占用, 继续⾃旋式的等待(并不放弃 CPU)
  • 3)重量级锁:
    • 如果竞争进⼀步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的 mutex
      • 执行加锁操作, 先进入内核态
      • 在内核态判定当前锁是否已经被占用
      • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
      • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起等待被操作系统唤醒.
      • 经历了⼀系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁

当前JVM中,只提供了 "锁升级" ,不能 "锁降级"。

3.2 其他优化操作

锁消除

也是编译器优化的一种体现。编译器会判定,当前这个代码逻辑是否真的需要加锁,如果确实不需要加锁,但是已经写了 synchronized ,就会自动把 synchronized 消除。

锁粗化

即为锁的粒度,加锁和解锁之间,包含的代码越多,就认为锁的粒度越粗,如果包含的代码越少,就认为锁的粒度越细。++注意++,不是指代码的行数多少,而是指实际执行的指令/时间。

在一个代码中,反复针对一个细粒度的代码加锁,就可能被优化为更粗粒度的加锁。

如以下的代码,细粒度的写法,就是执行多次的加锁解锁操作:

java 复制代码
for(int i = 0;i < 500; i++) {
    synchronized(this) {
        count++;
    }
    synchronized(this) {
        count++;
    }
    synchronized(this) {
        count++;
    } 
}

将上述代码优化成粗粒度的写法,即优化成一次加锁解锁:

java 复制代码
for(int i = 0;i < 500; i++) {
    synchronized(this) {
        count++;
        count++;
        count++;
    }
}

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

4.1 Callable 接口

Callable是一个接口,相当于把线程包装成一个返回值,++方便通过多线程的方式计算结果++。

Callable 和 Runnable 接口是并列的关系,只不过Callable是一个泛型接口,它有一个V call()方法,会抛出 Exception 异常,而Runnable没有返回值,它有一个void run()抽象方法。

Callable 和 Runnable 一样,只是定义了一个任务,Callable是定义了一个带有返回值的任务,并没有真的在执行该任务,执行还是需要搭配 Thread 线程对象。

而 Thread 的构造方法,没有提供版本,传入Callable对象:

或者说,Thread 类的构造方法不能直接传入带返回值的任务,在Java中,Thread构造器只接受Runnable(无返回值)或直接重写run方法。Callable是有返回值的,且它的call还会抛出异常,不能直接传给Thread。Thread 本身的设计就是"执行但不返回结果"的命令模式

如何让 Thread 执行带返回值的任务?

用 FutureTask 包装:FutureTask 实现了 Runnable,所以可以塞进 Thread 来执行;之后通过 FutureTask.get() 获取结果,即获取到FutureTask 的返回值,这个返回值就来自于 Callable 的 call()方法。

get 方法会抛出两个异常,其中一个是 InterruptException,说明 FutureTask中的get()方法也是一个带有阻塞的方法,可以阻塞等待,如果当前线程执行完毕,get 就拿到返回结果,如果没有执行完毕,会一直阻塞等待直到执行完成。

示例:打印 1+2+......+100 的结果:

也可以直接使用 Runnable 实现示例的功能,就不会因为使用 Callable 而需要 FutureTask 进行中转:

只不过需要额外创建一个变量来接受结果,且需要手动调用 join 方法来阻塞等待线程执行完毕:

到这里,顺便复习一下创建线程的写法:

  1. 继承 Thread 类,定义单独的类实现,或者匿名内部类实现。
  2. 实现 Runnable 接口,定义单独的类实现,或者匿名内部类实现。
  3. Lambda 反射实现。
  4. 实现 Callable 接口,定义单独的类实现,或者匿名内部类实现。
  5. 线程池,ThreadFactory类。

4.2 ReentrantLock 类

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

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等。
  • trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁。
  • unlock(): 解锁。

与 synchronized 对比,还是 synchronized 更好。

synchronized 和 ReentrantLock 之间的区别:

  1. synchronized 是关键字(内部实现是JVM通过C++实现的),ReentrantLock 是Java标准库提供的类。
  2. synchronized 通过代码块控制加锁解锁,ReentrantLock 需要 lock/unlock 方法,需要注意漏写的问题。
  3. ReentrantLock 除了提供 lock,unlock 外,还提供了一个方法,trylock(),设置超时时间,等待时间达到超时时间再返回 true/false,注意它是不会阻塞的,加锁成功就返回true,不成功就返回false,让调用者判定返回值,决定接下来怎么做。
  4. synchronized 是不公平锁,ReentrantLock 默认是不公平锁,但是提供了公平锁的实现,只要在构造方法时传入 true,ReentrantLock就变成了一个 公平锁。
  5. ReentrantLock 搭配的 等待通知 机制是 Condition 类,相比于 wait和notify,功能更强大。

4.3 Semaphore 信号量

Semaphore类 表示信号量, 用来表示 "可用资源的个数",本质上就是⼀个计数器。能够协调多个线程/进程之间的资源分配。

  • 申请一个资源,计数器就 -1。
  • 释放一个资源,计数器就 +1。
  • 计数器为0,继续申请,就会阻塞等待。

Semaphore 的申请操作,叫做 P操作 ,释放操作,叫做 V操作。PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。

在Java中,使用 acquire 和 release 来表示 P 和 V 操作,acquire()是带有阻塞的方法。

特殊情况:

  • 当指定 Semaphore 的可用资源为1个信号量时,要么取值是1,要么是0,也称Semaphore(1) 为二元信号量,就等价于 锁。
  • 那么普通的信号量,就相当于锁的更广泛的推广。如果是普通的 N 的信号量,就可以限制同时可以有多少个线程来执行某个逻辑。

4.4 CountDownLatch

在使用多线程时,经常把一个大任务拆分成多个小任务,使用多线程执行这些子任务,从而提高程序的效率。

那么,如何知道,这多个子任务都完成了,也就是整个任务都完成了?

------ 就是通过 CountDownLatch 类来观察的,它的作用就是同时等待 N 个任务执行结束。

  1. CountDownLatch 的构造方法指定 参数,描述拆成了多少个任务。
    1. 例如,构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成
  2. 每个任务执行完毕后,都调用一次 countDown() 方法,该方法每调用一次,CountDownLatch内部的计数器就会 减1。
    1. 当一共调用了10次 countDown,说明任务全都完成了
  3. 在主线程中调用 await() 方法,阻塞等待所有任务执行完毕,当计数器归零后,所有等待的线程会被释放,即所有线程执行任务完毕,await 就会立即返回。

示例:现在把整个任务拆成10个部分,每个部分视为一个子任务,把这10个子任务丢到线程池中,让线程池中工作线程执行,或者也可以安排10个独立线程执行。


JUC中,除了上述的常见类,还有 原子类和线程池,这两个都在前面介绍过了,这里不再展开。

5.线程安全集合类

5.1 多线程环境使用ArrayList

  • 方法1:自行加锁【推荐】:分析清楚要把哪些代码打包到一起,成为一个 "原子" 操作。
  • 方法2:套壳,使用 Collections.synchronizedList(new ArrayList) 方法,放回的 List 的各种关键方法都带有 synchronized ,类似于 Vector、Hashtable、StringBuffer。
  • 方法3:使用 CopyOnWriteArrayList
    • CopyOnWrite容器即写时复制的容器,写时拷贝:
    • 例如,CopyOnWrite容器 其中元素为1,2,3,4,当多线程读取时,不会有问题。但是一旦某个线程进行写操作,比如修改 1 -> 100,
    • 由于CopyOnWrite,修改元素并不会真的在容器中修改,而是先将当前容器进行Copy,复制出一个新的容器,然后在新的容器中修改元素,
    • 注意,在复制过程中,如果其他线程在读,就直接读到的是旧版本的数据,即原容器中的数据,当新版本的容器复制完毕且修改完毕,就将原容器的引用指向新容器,确保读取过程中,要么读的是旧数据,要么读到的是新数据,不会读到修改一半的数据。
    • 这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是⼀种读写分离的思想,读和写不同的容器

CopyOnWrite容器的优点,就是在读多写少的情况下,性能很高,不需要加锁竞争,不会产生阻塞。

而它的缺点也很明显:

  1. 当数组特别大时,占用内存较多,非常低效
  2. 如果多个线程同时修改,那就要复制多份的数组,修改完要引用的时候,会难以分辨要引用到哪一个线程的数组,可能每个线程修改的部分不一样。

所以它适合特定的场景,例如服务器进行重新加载配置的时候。

5.2 多线程环境使用队列

  1. ArrayBlockingQueue:基于数组实现的阻塞队列。
  2. LinkedBlockingQueue:基于链表实现的阻塞队列。
  3. PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列。
  4. TransferQueue:最多只包含一个元素的阻塞队列。

5.3 多线程环境使用哈希表

HashMap本身是线程不安全的,在多线程环境下哈希表可以使用:

**1)**Hashtable:就是给各种 public 方法都加上 synchronized,

  • 这相当于针对 Hashtable 对象本身(this)加锁,如果多线程访问同一个Hashtable 就会直接造成锁冲突,即任意两个线程,访问两个不同的元素都会产生锁竞争,影响效率
  • size 属性也是通过 synchronized 来控制同步,也是比较慢的,即当多个线程添加或者删除哈希表中的元素,size也需要相应的++ / --,而此过程中只有一个锁,需要阻塞等待某一个线程添加或删除元素后size++/-- 后,释放锁再进行下一步的操作,保证 size 正确,效率低下。
  • ⼀旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝(需要一个更大的数组把旧哈希表中的所有元素全部搬到新哈希表中), 效率会非常低,即一旦某一个线程往哈希表中添加元素,表刚好满了,触发了扩容,就必须整个阻塞等待到该线程完成对哈希表的扩容。

2) ConcurrentHashMap:它是按照 级别进行加锁的,而不是像Hashtable一样给整个哈希表加一个全局锁,有效降低了了锁冲突的概率。

相比于 Hashtable 做出了⼀系列的改进和优化,以 Java1.8 为例,

  • 读操作没有加锁 (但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁 ,加锁的方式仍然是是用 synchronized, 但是不是锁整个对象,而是 "锁桶 " (用每个链表的头结点作为锁对象),大大降低了锁冲突的概率.。
    • 即如果修改的两个元素,在不同的 链表/桶 上,本身就不涉及线程安全问题,因为修改的是不同的变量,而如果修改同一个链表/桶上的两个元素,可能会有线程安全问题,比如把这个两个元素插入到同一个元素的后边,那么这两个元素就会对这同一个元素的后边的位置产生竞争(谁先插入谁后插入)。
    • 上述的锁桶方式是从 jdk8 时期开始引入的,在这之前,ConurrentHashMap 采用 "分段锁" 方案,就是把这些链表分成几组,每个组安排一个锁,而不像现在这样每个链表都安排一个锁,显然没有这种方法好。
  • 充分利用 CAS 特性,比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
  • 优化了扩容方式:化整为零
    • 发现需要扩容的线程, 只需要创建⼀个新的数组, 同时只搬⼏个元素过去.
    • 扩容期间, 新老数组同时存在.
    • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运⼀小部分元素.
    • 搬完最后⼀个元素再把老数组删掉.
    • 这个期间,插入只往新数组加,查找需要同时查新数组和老数组。
相关推荐
十五年专注C++开发10 天前
C++中TAS和CAS实现自旋锁
c++·cas·原子操作·tas
lee_curry11 天前
线程中断,等待,唤醒与ThreadLocal
java·线程·juc·threadlocal·中断
Zzzzmo_12 天前
【JavaEE】多线程04—线程池/定时器
java·线程池·定时器·javaee
小Y._13 天前
AQS同步器核心原理深度剖析
java·源码分析·juc·aqs
小Y._13 天前
ConcurrentHashMap高效并发机制深度解析
java·并发·juc·concurrenthashmap
lee_curry16 天前
Java中关于“锁”的那些事
java·线程·并发·juc
想带你从多云到转晴16 天前
02、JAVAEE--多线程(二)
java·开发语言·javaee
菜鸟小九19 天前
JUC(共享模型之管程、synchronized、wait、park、活跃性、renetrantlock、条件变量)
java·开发语言·juc
lee_curry19 天前
JUC第一章 java中基础概念和CompletableFuture
java·多线程·并发·juc