JavaEE-多线程进阶

✏️作者:银河罐头
系列专栏:JavaEE

“种一棵树最好的时间是十年前,其次是现在”

目录

  • 常见的锁策略
    • 乐观锁 vs 悲观锁
    • 轻量级锁 vs 重量级锁
    • 自旋锁 vs 挂起等待锁
    • 互斥锁 vs 读写锁
    • 公平锁 vs 非公平锁
    • 可重入锁 vs 不可重入锁
  • CAS
    • 什么是 CAS
    • CAS 的应用场景
      • 实现原子类
      • 实现自旋锁
    • CAS 的 ABA 问题
  • Synchronized原理
    • 基本特点
    • 加锁工作过程
    • 锁消除
    • 锁粗化
  • JUC
    • Callable 接口
    • ReentrantLock
    • 原子类
    • 信号量 Semaphore
    • CountDownLatch
  • 线程安全的集合类
    • 多线程环境使用 ArrayList
    • 多线程环境使用哈希表

常见的锁策略

乐观锁 vs 悲观锁

这不是两把具体的锁,而是两类锁。

  • 乐观锁:预测锁竞争不是很激烈(做的工作相对更少)

  • 悲观锁:预测锁竞争会很激烈(做的工作可能相对更多)

乐观和悲观,唯一的区分是预测锁竞争激烈程度。

举个栗子:

疫情放开后,乐观锁认为新冠毒性减弱,可以随意旅游到处吃喝玩乐,阳了也没事。而悲观锁仍然宅在家里并屯粮屯药,做好防护。

轻量级锁 vs 重量级锁

轻量级锁加锁解锁开销小,效率更高;

重量级锁加锁解锁开销大,效率更低。

多数情况下,乐观锁也是一个轻量级锁;悲观锁也是一个重量级锁(不能完全保证)

自旋锁 vs 挂起等待锁

自旋锁是一种典型的轻量级锁,挂起等待锁是一种典型的重量级锁。

举个栗子:张三喜欢上一个妹子,当张三和妹子表白后,妹子说:你是个好人,我已经有男朋友了。

自旋锁:张三不死心,死皮赖脸,每天和妹子说早安晚安,只要妹子和男朋友分手了,张三就立刻抓住机会上位。

挂起等待锁:张三说我愿意等,不打扰妹子,张三让妹子如果分手了就告诉他。过了很久很久,妹子想起张三了,表示愿意和张三试试,这个很长的时间段里,妹子可能已经换过好几个男朋友了。

自旋锁:一旦锁被释放,就能第一时间感知到,从而有机会获取到锁,很明显,自旋锁占用了大量的系统资源。

挂起等待锁:不占用CPU,CPU可以干别的事。

锁策略,是你是实现锁的时候,出现了竞争,怎么办。

互斥锁 vs 读写锁

互斥锁:就像前面学过的 synchronized 这样的锁,提供加锁,解锁两种操作,如果一个线程加锁了,另一个线程尝试加锁就会阻塞等待。

读写锁:提供了3种操作:1.针对读加锁 2.针对写加锁 3.解锁

多线程针对同一个变量并发读,这个时候是没有线程安全问题的,也不需要加锁控制。

读锁和读锁之间没有互斥;写锁和写锁之间存在互斥;写锁和读锁之间存在互斥。

当前代码中如果只有读操作,加读锁,有写操作,加写锁。

假设有一组线程都去读(加读锁),这些线程之间是没有锁竞争的,也没有线程安全问题(又快又准)

假设这组线程有读又有写,才会有锁竞争。

在开发场景中,读操作非常频繁,比写操作频率高很多。

在 Java 标准库里面也提供了读写锁的具体实现。(两个类,读锁类,写锁类)

公平锁 vs 非公平锁

这里的公平意思是"先来后到"。

举个栗子:

JavaEE-多线程进阶_第1张图片

公平锁:当妹子分手后,就由等待队列中最早追妹子的舔狗上位。

JavaEE-多线程进阶_第2张图片

非公平锁:“雨露均沾”,3个舔狗一拥而上,各凭本事

JavaEE-多线程进阶_第3张图片

操作系统和 Java synchronized 原生都是"非公平锁",操作系统这里针对加锁的控制,本身就依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑到这个线程等待锁多久了。

要想实现公平锁,就要在这个基础上引入一些额外的东西,比如队列,让这些加锁的线程去排队。

可重入锁 vs 不可重入锁

不可重入锁:一个线程针对一把锁,连续加锁2次,出现死锁。

可重入锁:一个线程针对一把锁,连续加锁多次都不会出现死锁。

CAS

什么是 CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“

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

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。
// CAS 伪代码
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

JavaEE-多线程进阶_第4张图片

在上述交换过程中,其实不太关心 B 的后续情况,更关心 V 这个变量的情况,说是交换,也可以理解为是赋值

如果 V == A 就把 B 的值赋给 A ;如果 V != A , 则不进行交换操作。

上述这个 CAS 的过程,并非是通过一段代码实现的,而是通过一条 CPU 指令完成的。也就是说 CAS 操作是原子的。那么就可以在一定程度上回避线程安全问题。

这样一来,我们解决线程安全问题除了加锁,还可以考虑 CAS 这种思路

CAS 的应用场景

实现原子类

原子类,Java标准库里提供的类

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        //这些原子类就是基于 CAS 实现了自增,自减等操作,此时进行这类操作不加锁也是线程安全的。
        AtomicInteger count = new AtomicInteger(0);

        Thread t1 = new Thread(()->{
            for(int i = 0;i < 50000;i++){
                // Java 不支持运算符重载,所以只能使用普通方法来自增自减
                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.get());
    }
}
//输出:
100000

伪代码实现:

JavaEE-多线程进阶_第5张图片

另一个线程改的是内存 value,寄存器是每个线程都有自己的一份上下文

JavaEE-多线程进阶_第6张图片

原子类的实现,每次修改之前都要确认这个值是否符合要求。

CAS这个方法属于特殊方法,只是特定场景能使用,没有那么通用;而 synchronized 属于通用方法,各种场景都能使用。

实现自旋锁

//自旋锁伪代码
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;
   }
}
//检测当前 owner 是否为 null,是 null 就交换,也就是把当前线程的引用赋给owner,复制成功,CAS()返回 true,循环结束;如果锁已经被别的线程占用了,owner不是null,则 CAS 不会进行赋值,CAS()返回 false,循环继续,继续进行下一次判定,

Java并不是直接提供了一个方法叫 CAS , Java原生提供的 CAS 相关的方法比较复杂,此处的 CAS 相当于是一个简化的表示方式。

CAS 的 ABA 问题

CAS 在运行中的核心,就是检查 value 和 oldValue 是否一致,如果一致就认为 value 中途没有被修改过,所以就进行下一步交换操作。

然鹅,这里可能是没有被修改过,有可能是修改过又还原回来了。

设 value = A ; 可能 value 始终为 A,也有可能是 value 本来是 A,被改成了 B,又被还原成了 A

ABA这个情况,大部分情况下,其实不会对代码/逻辑产生太大影响。但是不排除一些"极端情况",也是可能造成影响的。

有一种极端情况,实际开发中概率非常低。

举个栗子:

张三要去 ATM 取钱,假设张三当前账户余额为 1000,张三准备取 500。当按下取款这一瞬间,机器卡了下,张三忍不住多按了几下,可能会产生 bug。可能会触发重复扣款的操作。

JavaEE-多线程进阶_第7张图片

针对当前问题,采取的方案是加入一个版本号,想象成初始版本号是1,每次修改版本号都 +1,然后进行 CAS 的时候,不是以金额为基准了,而是以版本号为基准。

此时,版本号要是没变,就是一定没有发生改变(版本号不能降低,只能增长)

基于版本号的方式也是乐观锁的一种典型实现方式。

Synchronized原理

基本特点

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

  1. synchronized 既是一个乐观锁,也是一个悲观锁。

synchronized 默认情况下是乐观锁,如果发现当前锁冲突很激烈的情况下就会变成悲观锁。

  1. synchronized 既是一个轻量级锁,也是一个重量级锁。

synchronized 默认情况下是轻量级锁,如果发现当前锁冲突很激烈的情况下就会变成重量级锁。

  1. synchronized 这里的轻量级锁是基于自旋锁实现的;

    synchronized 这里的重量级锁是基于挂起等待锁实现的。

  2. synchronized 不是读写锁

  3. synchronized 是非公平锁

  4. synchronized 是可重入锁

上述 6 种锁策略,可以视为是"锁的形容词"

加锁工作过程

synchronized 内部还有一些优化机制,存在的目的是为了让这个锁更高效,更好用。

1.锁升级/锁膨胀

1)无锁

2)偏向锁

3)轻量级锁

4)重量级锁

synchronized(locker){
    
}
//当代码执行到这个代码块之后,加锁过程可能会经历前面说的这几个阶段
  • 偏向锁

进行加锁的时候,首先会经历 偏向锁 状态,

偏向锁并不是真正的加锁,而只是占个位置,有需要就加锁,没需要就算了。

举个栗子:

假设张三是个妹子,当她谈了一个男朋友,谈的久了就想换。

如何和男朋友分手?

挑对方毛病上纲上线,使劲作。技术活,成本太高,来得慢。

张三想到了一个更加高效的方式,只和小哥哥暧昧,不和他确认关系(有情侣之实,无情侣之名),这样一来张三想换男朋友成本就非常低了。

但是这么做就有个风险。

无情侣之名(没有对小哥哥加锁),如果有竞争对手来了(其他妹子来抢小哥哥)。

如果有竞争对手出现,我就立即和小哥哥确认关系,立刻官宣,加锁,让小哥哥离别的妹子远点

上述这个过程,就是"偏向锁"的过程,相当于"懒汉模式"的"懒加载"一样。

“非必要,不加锁”

synchronized 并不是真正加锁,,先偏向锁状态,做个标记(这个过程是非常轻量的),如果整个使用锁的过程中都没有出现锁竞争,那么就不用进行其他同步操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.。另一个线程也只能阻塞等待了。

偏向锁是 synchronized 内部的实现,也就是 JVM 里面 C++ 代码实现的,不是咱们在 Java 代码中能写出来/能看到的。

偏向锁也是有开销的, 所以懒汉模式双重判断 null 就是为了防止偏向锁的添加?

是的,开销高低都是相对的,再低也肯定不如一个 if 判定低

  • 轻量级锁

当 synchronized 发生锁竞争时,就会从偏向锁变成轻量级锁(自旋锁)。此处的轻量级锁就是通过 CAS 来实现的。

如果别人很快就释放锁了,自旋是划算的,如果迟迟不释放锁,一直自旋并不划算。

自旋操作一直让 CPU 空转,比较浪费 CPU 资源。

因此这里的 自旋不会一直进行,而是达到一定时间/重试次数,就不再自旋了。也就是所谓的"自适应"

  • 重量级锁(挂起等待锁)

如果竞争进一步加剧,自旋不能快速获取到锁,就会膨胀为重量级锁。

重量级锁则是基于操作系统原生的 API 来进行加锁。

Linux 原生提供了 mutex 一组 API。操作系统内核提供的加锁功能。

这个锁会影响到线程的调度。

此时,如果线程加了重量级锁,并且发生锁竞争,此时线程就会被放到阻塞队列中,暂时不参与 CPU 调度了。

直到锁被释放了,线程才有可能被调度到,有机会获取到锁。

当前锁只能升级,只要是指定的锁对象,已经被升级了,就回不了头了。

除非是另外搞一个其他的锁对象,还是重复刚才的偏向锁,轻量级锁,重量级锁的过程。

锁消除

编译器智能的判定,看当前代码是否需要加锁,如果这个场景不需要加锁,人为加了,就自动把锁给清除掉。

StringBuffer 关键方法中都带有 synchronized ,但是如果在单线程中使用 StringBuffer ,synchronized 加了也白加,此时编译器就会把加锁操作给干掉。

锁粗化

锁的粒度:synchronized 包含的代码越多,锁的粒度就越粗;包含的代码越少,粒度就越细。

通常情况下认为锁的粒度细一点比较好,锁的粒度越细,能并发的代码就越多。

但是有些情况下,锁的粒度粗一点比较好,eg:两次加锁解锁之间间隙非常小,此时不如一次大锁就搞定了。

JavaEE-多线程进阶_第8张图片

举个栗子:

张三给领导汇报工作:

1.打电话,汇报工作,挂电话;

2.打电话,汇报工作,挂电话;

3.打电话,汇报工作,挂电话;

这样频繁的打电话,领导也烦了。

最好的方式是:

打电话,汇报工作, 汇报工作, 汇报工作,挂电话

JUC

JUC(java.util.concurrent) ,放了并发编程(多线程编程)相关的组件。

各种集合类,scanner,random…

并发编程,更广义的概念。多线程编程是实现并发编程的一种具体方式(Java提供的默认的方式)。

除了这种方式,还有很多其他方式(其他并发编程模型)

Callable 接口

类似于 Runnable 一样,

Runnable 用来描述一个任务,没有返回值。

Callable 也是用来描述一个任务,有返回值。

如果需要一个线程单独的计算出某个结果来, 用 Callable 比较合适

JavaEE-多线程进阶_第9张图片

image-20230123145746791

这里不能直接把 callable 传入到 Thread 的构造方法里,需要套上一层其他的辅助类

image-20230123145937296

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo{
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //使用 callable 来计算 1 + 2 + 3 + ... + 1000
        Callable callable = new Callable() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i = 1;i <= 1000;i++){
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);//新线程执行futureTask内部callable的call方法,把计算结果保存到futureTask对象中,futureTask.get()一直阻塞到计算完成获取到结果
        t.start();
        Integer result = futureTask.get();//get 就是获取结果,直到 callable 执行完毕,get 才阻塞完成,才获取到结果。
        System.out.println(result);
    }
}

创建线程的方式又进一步的扩充了

ReentrantLock

entry 条目/入口

entrant , entry 的变形

ReentrantLock 是标准库提供的另一种锁,“可重入锁”

synchronized 是基于代码块来加锁解锁的,而 ReentrantLock 是使用了 lock 方法和 unlock 方法来加锁解锁

ReentrantLock reentrantLock = new ReentrantLock();
        reentrantLock.lock();

        reentrantLock.unlock();
//这样的写法可能会导致最后的 unlock()执行不到(如果中间执行了return 或抛异常等操作)

JavaEE-多线程进阶_第10张图片

解决办法是把 unlock() 放到 finally{} 中

上述是 ReentrantLock 的劣势,但是也是有优势的

  1. ReentrantLock提供了公平锁的实现。
ReentrantLock reentrantLock = new ReentrantLock(true);//参数为 true => 公平锁
//不加或参数为 false 则是非公平锁

而 synchronized 是非公平锁,要想变成公平锁得在内部引入阻塞队列等操作保证"先来后到"。

​ 2. 对于 synchronized 来说,提供的加锁操作就是死等,只要获取不到锁,就一直等;

而 ReentantLock 提供了更灵活的等待方式,tryLock().

image-20230123153401420

1)无参数版本:能加锁就加锁,加不上锁就放弃。

2)有参数版本: 指定一个等待时间,等待时间到了还没加上锁就放弃。

image-20230123153700969

tryLock()有返回值,返回 true加锁成功就解锁 ; 没加上锁返回false就不解锁了。

JavaEE-多线程进阶_第11张图片

  1. ReentrantLock提供了一个更方便的等待通知机制

synchronized 搭配的是 wait , notify. notify是随机唤醒一个 wait 的线程。

ReentantLock 搭配的是一个 Condition 类,进行唤醒的时候可以唤醒指定的线程。

原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference

以 AtomicInteger 举例,常见方法有

addAndGet(int delta);   i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;

基于 CAS ,确实是更高效的解决了线程安全问题,但时候 CAS 不能代替锁,CAS 适用范围是有限的,不像锁适用范围那么广

信号量 Semaphore

举个栗子:

停车场

停车场的车位是有上限的。很多停车场会在入口这里显示个牌子,牌子上写:当前空闲车位有 XX 个。

每次有车从入口进去,计数器就 -1;每次有车从出口出来,计数器就 +1.

如果当前停车场的车满了,计数器就为0。

这个时候,如果还有车想停,

1)在这里等

2)不等了,去找别的停车场

信号量,本质上就是一个计数器,描述了可用资源的个数。

P 操作:申请一个可用资源,计数器 -1;

V 操作:释放一个可用资源,计数器 +1.

如果计数器为0了还进行 P 操作就会阻塞等待。

考虑一个计数初始值为 1 的信号量,针对这个信号量的值只有 0 和 1 两种情况(信号量不能是负值)

执行一次 P 操作,计数器:1 -> 0;

执行一次 V 操作,计数器:0 -> 1;

如果已经进行了一次 P 操作,那么在进行一次 P 操作就会阻塞等待。

锁可以视为计数器为 1 的二元信号量(只有0 1两种取值)。

锁是信号量的一种特殊情况,信号量是锁的一般表达

代码中也是可以用 Semaphore 来实现类似于 锁 的效果来保证线程安全。

P 操作一般用 acquire 申请

V 操作一般用 release 释放

Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
//semaphore.acquire(2);
//acquire() 还可以传参数,一次申请多个

JavaEE-多线程进阶_第12张图片

计数器为0了还进行 P 操作 , 阻塞等待

CountDownLatch

用的不多,特定场景用的小工具。

举个栗子:

想想有一个跑步比赛,开始时间是明确的(裁判的发令枪),结束时间则是不明确的(要等所有选手都冲过终点线)

为了等待这个跑步比赛结束,就引入了这个 CountDownLatch

主要是2个方法。

  1. await (wait 是等待,a => all),主线程来调用这个方法。

  2. countDown 表示选手冲过了终点线。

countDown在构造的时候,指定一个计数(选手的个数)。

例如,指定四个选手去比赛。初始时调用await方法,就会堵塞。每个选手冲过终点,都会调用countDown方法。前三次调用countDown,await没有任何影响。第四次调用countDown,await就会被唤醒,解除阻塞,此时就可以认为是整个比赛都结束了。

比如下载一个大文件

视频文件好几个G。多线程下载,把一个大的文件切分成几个小的文件。安排多个线程分别下载。当前家用的带宽下载速度是很快。很多时候是应用程序代码本身不能充分利用这样的带宽。

多线程下载不是充分利用了CPU,而是充分利用了带宽。下载是IO操作,和CPU关系不大。此处就可以使用countDownLatch来判断是不是整体都下载完了。

线程安全的集合类

Java标准库里的大部分集合类都是线程不安全的。多个线程使用同一个集合类对象很有可能出现问题。

Vector , Stack , HashTable 这几个类是少数的线程安全的集合类(不建议用), 关键方法带有synchronized.

多线程环境使用 ArrayList

  1. 自己加锁,自己使用synchronized 或 ReentrantLock[常见]

  2. Collections.synchronizedList 这里会提供一些 ArrayList 相关的方法,同时是带锁的,使用这个方法把集合类套一层。

  3. CopyOnWriteArrayList

​ 简称 COW,也叫做"写时拷贝"。如果针对这个ArrayList进行读操作,不做任何额外的工作。如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的,本质上就是一个引用之间的赋值(原子的).

很明显,这种方案优点是不需要加锁,缺点则是要求这个ArrayList不能太大,只是适用于这种数组比较小的情况下.

服务器程序的配置维护。
(mysql, my.ini)
一个程序可能包含很多个子功能,有的功能想要使用,有的不想要使用,有的希望功能应用不同的形态,就可以使用一系列的开关选项来控制这个程序的工作状态。服务器程序的配置文件可能会需要进行修改,修改配置可能就需要重启服务器才能生效。

但是重启操作可能成本比较高,假设一个服务器重启需要花5min(往小了说的)。如果你有20台这样的服务器,总的重启时间就得有100min.

因此很多服务器都提供了"热加载"(reload)这样的功能,通过这样的功能就可以不重启服务器,实现配置的更新。热加载的实现就可以使用刚才的 写时拷贝 的思路。

新的配置放到新的对象中。加载过程中,请求仍然基于旧配置进行工作。当新的对象加载完毕,使用新对象替代旧对象(替换完成之后,旧对象就可以释放了)

多线程环境使用哈希表

HashMap 是线程不安全的。

HashTable 是线程安全的(给关键方法加了 synchronized)

更推荐使用的是 ConcurrentHashMap , 更优化的线程安全哈希表。

ConcurrentHashMap 进行了哪些优化?比 HashTable 好在哪里?和 HashTable 之间的区别是啥?

  1. 最大的优化之处,ConcurrentHashMap 相比于 HashTable 大大缩小的锁冲突的概率,把一把大锁转变为一把小锁了。

HashTable 是直接在方法上加 synchronized,相当于是给 this 加锁。只要操作操作哈希表上的任何元素都可能会发生锁冲突。

但是实际上,仔细思考不难发现,基于哈希表的结构特点,有些元素在进行并发操作的时候是不会发生线程安全问题的,也就不需要使用锁控制。

JavaEE-多线程进阶_第13张图片

此时元素 1 2在同一个链表上,如果线程 A 修改元素 1,线程 B修改元素 2。(修改可能包含增删改)

这种情况需要加锁,会有线程安全问题。比如这2个元素相邻,此时并发的插入/删除,就需要修改这两节点相邻节点的next指向。

如果线程 A 修改元素 3,线程 B 修改 元素 4,这种情况不需要加锁。这个情况就相当于多个线程修改不同的变量。

HashTable 锁冲突的概率太大了,任何两个元素的操作都会有锁冲突,即使是在不同的链表上。(这也是不用HashTable的主要原因)

ConcurrentHashMap 的做法是每个链表有各自的锁(而不是大家共用一把锁了)

具体来说就是用每个链表的头结点作为锁对象(两个线程针对同一个所对象加锁才会有锁竞争,才会有阻塞等待)

JavaEE-多线程进阶_第14张图片

此时把锁的粒度变小了。

针对 1 2 这种情况,是针对同一个对象加锁,会有锁竞争,要保证线程安全。

针对 3 4 这种情况,是针对不同对象加锁,不会有锁竞争,没有阻塞等待

上面谈到的情况是 JDK1.8 以后的情况。

在 1.7 和之前 ConcurrentHashMap 使用的是"分段锁"

JavaEE-多线程进阶_第15张图片

"分段锁"本质上也是缩小锁的范围,从而降低锁冲突的概率,但是这种做法不够彻底。一方面粒度切分的还不够细,另一方面代码的实现也更繁琐。

  1. ConcurrentHashMap 做了一个激进的操作。针对读操作不加锁,只针对写操作加锁。

读和读之间没有冲突,写和写之间有冲突,读和写之间也没有冲突。很多场景下,读写之间不加锁控制,可能会读到一个写了一半的结果,如果写操作不是原子的(volatile + 原子的写操作),此时读就可能会读到写了一半的数据,相当于脏读了

  1. ConcurrentHashMap 内部充分使用了 CAS, 通过这个进一步削减加锁操作的数目,比如维护元素个数

  2. 针对扩容,采取了"化整为零"的方式

HashMap/HashTable 扩容:创建一个更大的数组空间,把旧的数组上的链表上的每个元素搬运到新数组上(插入+ 删除)。这个扩容操作会在某次 put 的时候进行触发。如果元素个数特别多,就会导致搬运操作特别耗时。就会出现某次 put 比平时的 put 卡很多倍

ConcurrentHashMap 中 ,扩容采用的是每次搬运一小部分元素的方式。创建新的数组,旧的数组也保留。每次put 操作都往新数组上添加,同时进行一部分搬运(把一部分旧的元素搬运到新数组上)。每次get的时候,旧数组和新数组都查询。每次remove的时候,把元素删了就行了。

经过一段时间,所有的元素都搬运好了,旧数组就可以释放了。

你可能感兴趣的:(JavaEE初阶,java-ee,java,数据库)