并发编程面试

进程,线程,协程的区别

  1. 进程: 操作系统进行资源分配和调度的基本单位。每个进程有独立的内存空间。进程通讯就采用共享内存,MQ,管道。

  2. 线程: 一个进程可以包含多个线程,线程就是CPU调度的基本单位。一个线程只属于某一个进程。线程之间通讯,队列,await,signal,wait,notity,Exchanger,共享变量等等都可以实现线程之间的通讯。

  3. 协程:
    协程是一种用户态的轻量级线程。它是由程序员自行控制调度的。可以显示式的进行切换。
    一个线程可以调度多个协程。
    协程只存在于用户态,不存在线程中的用户态和内核态切换的问题。协程的挂起就好像线程的yield。
    可以基于协程避免使用锁这种机制来保证线程安全。

    单独的拿协程和线程做一个对比:
    更轻量: 线程一般占用的内存大小是MB级别。协程占用的内存大小是KB级别。
    简化并发问题: 协程咱们可以自己控制异步编程的执行顺序,协程就类似是串行的效果。
    减少上下文切换带来的性能损耗: 协程是用户态的,不存在线程挂起时用户态和内核态的切
    换,也不需要去让CPU记录切换点。
    协程优化的点: 协程在针对大量的IO密集操作时,协程可以更好有去优化这种业务。

创建线程的方式

  • 直接继承Thread
  • 实现Runnable接口
  • 实现Callable接口(Runnable接口)
  • 通过线程池的方式获取

如何结束线程

  1. stop这种就不用说了。
  2. 现在结束线程比较优雅的方式只有一个, run方法结束 (正常结束,异常结束)
    每个线程都有一个中断标记位,这个标记位默认是false。
    当你执行这个线程的interrupt方法后,这个标记位会变为true
    while(!Thread.currentThread.isInterrupted())
    
    当你线程处于阻塞的状态下,比如await,wait,在阻塞队列,sleep等等,此时如果被中断,会抛出InterruptedException
    也可以直接指定共享变量。
    volatile boolean flag = false;
    run(){
    	while(!flag){
    		// 处理任务!!
    	}
    }
    

Thread和Runnable的区别

每一个线程其实就是一个Thread对象

  • 一个是继承和接口
  • Runnable接口定义run方法的逻辑。多个Thread对象可以执行相关的逻辑

为什么wait和notify方法要写在同步块中

当一个线程需要调用对象的wait()方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态直到其他线程调用这个对象上的notify()方法。同样的,当一个线程需要调用对象的notify()方法时,它会释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以他们只能在同步方法或者同步块中被调用。如果你不这么做,代码会抛出IllegalMonitorStateException异常。

线程的生命周期是怎么样的

生命周期:对象从创建到销毁的全过程
线程的生命周期:线程对象(Thread)从开始到销毁的全过程
在这里插入图片描述

ThreadLocal的作用,和内存泄漏问题

在开发中会用到的方式就是 传递参数 。
ThreadLocal有两个内存泄漏问题:
key: key会在玩花活使用ThreadLocal时 ,在局部声明ThreadLocal,局部方法已经执行完毕,但是线程会指向ThreadLocalMap,ThreadLocalMap的key会指向ThreadLocal对象,这会导致ThreadLocal对象不回收。所以ThreadLocal在设计时,将key的引用更改为了弱引用,如果再发生上述情况,此时ThreadLocal只有一个弱引用指向,可以被正常回收。
value: 如果是普通线程使用ThreadLocal,那其实不remove也不存在问题,因为线程会结束,销毁,线程一销毁,就没有引用指向ThreadLocalMap了,自然可以回收。但是如果是线程池中的核心线程使用了ThreadLocal,那使用完毕,必须要remove,因为核心线程不会被销毁(默认),导致核心线程结束任务后,上一次的业务数据还遗留在内存中,导致内存泄漏问题。
在这里插入图片描述

伪共享问题以及处理方案

伪共享问题需要先掌握一下CPU缓存的事。
在这里插入图片描述
所谓的伪共享就是多个数据公用一个缓存行发生的问题。
当一个缓存行的64个字节,缓存了多个数据(ABCD),此时因为JVM的操作,A数据被修改了,但是对于CPU来说,我只能知道当前缓存行的数据被修改了,现在的数据不安全,需要重新的去JVM中将数据同步一次。
因为CPU执行的效率特别快,如果去主内存中同步一次数据,相对CPU的速度来说,就好像咱们执行代码时查询了一次数据库,很影响效率。想解决这个问题,避免其他线程写缓存行导致当前线程需要去主内存查询,可以让某个线程直接占满当前缓存行的64k大小即可。
占满缓存行,独自使用,其实就是利用空间换时间的套路。
long l1,l2,l3,l4,l5,l6,l7;
long value;
long l9,l10,l11,l12,l13,l14,l15;

CPU缓存可见性问题发生的原因

CPU缓存可见性的问题,就是在缓存行数据发生变化时,会发出通知,告知其他内核缓存行数据设置为无效。但是因为CPU厂商为了提升CPU的执行效率,经常会追加一些优化的操作,StoreBuffer,Invalidate Queue。这些就会导致MESI协议通知受到影响,同步数据没那么及时。

所以CPU内部提供了一个指令, lock前缀指令 ,如果使用了lock前缀指定去操作一些变量,此时会将数据立即写回到主内存(JVM),必然会触发MESI协议,类似StoreBuffer,Invalidate Queue的缓存机制也会立即处理。

单例模式的DCL为啥要加volatile

避免指令重排,获取到未初始化完成的对象。
单例模式的懒汉模式确保线程安全的机制DCL

public class MyTest {
	private static MyTest myTest;
	public static MyTest getInstance(){
		if(myTest == null) { // check
			synchronized (MyTest.class) { // lock
				if(myTest == null) { // check
					myTest = new MyTest();
				}
			}		
		}
		return myTest;
	}
}

DCL正常可以解决单例模式的安全问题,但是由于CPU可能会对程序的一些指令做出重新的排序,导致出现拿到一些未初始化完成的对象去操作,最常见的就是出现了诡异的NullPointException。
(扩展一下)volatile修饰myTest对象后,可以禁止CPU做指令重排。volatile的生成字节码指令后
有内存屏障(指令),内存屏障会被不同的CPU翻译成不同的函数,比如X86的CPU,会对
StoreLoad内存屏障翻译成mfence的函数,最终的指令就是lock前缀指令。
Java中new对象,可以简单的看成三个指令的操作。
1、开辟内存空间
2、初始化对象内部属性
3、将内存空间的地址赋值给引用

CAS

CAS就是将内存中的某一个属性,从oldValue,替换为newValue。保证原子性。

public class MyTest {
	private int value = 1;
	
	public static void main(String[] args) throws Exception {
		MyTest test = new MyTest();
		Unsafe unsafe = null;
		Field field = Unsafe.class.getDeclaredField("theUnsafe");
		field.setAccessible(true);
		unsafe = (Unsafe) field.get(null);
		// 获取内存偏移量
		long offset = unsafe.objectFieldOffset(MyTest.class.getDeclaredField("value"));
		// 执行CAS,这里的四个参数分别代表什么,你也要清楚~
		System.out.println(unsafe.compareAndSwapInt(test, offset, 0, 11));
		System.out.println(test.value);
	}
}

Java层面如何实现的CAS以及使用。

在Java中,是基于Unsafe类提供的native方法实现的。native是走的C++的依赖库

public final native boolean cas(Object 哪个对象, long 内存偏移量, Object 旧值, Object 新值);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

Unsafe类,不能直接new,只能通过反射的形式获取到Unsafe的实例去操作,不过一般业务开发中,基本不会直接使用到Unsafe类。

Java的CAS在底层是如何实现的。

Java层面的CAS,只到native方法就没了。底层是C++实现的,但是其实比较和交换(CAS),是CPU支持的原语。cmpxchg指令就是CPU支持的原语。如果在CPU层面,多核CPU并行执行CAS修改同一个属性,可能会导致出现问题。C++内部就可以看到针对cmpxchg指令前追加了lock前缀指令(多核CPU)

CAS存在的一些问题

ABA问题: 要修改的数据,最开始是A,但是你没修改成功,期间经过一些列的操作,后来又变回了A,此时你CAS会成功。 但是这个数据在最开始的A ---- 最后的A,这期间发生了什么事情,咱不清楚。如果业务有要求这个期间发生的问题也要纠结一下,那么你就需要换一种CAS的实现。利用版本号来确认。Java中提供了解决这种ABA问题的原子类。

public class AtomicStampedReference<V> {
	private static class Pair<T> {
	final T reference; // 你要修改的值
	final int stamp; // 版本号,你可以自行制定 戳~
	}
}

性能问题: CAS的性能嘎嘎快,一个层面是他属于CPU原语层面上的指令。还有一个优点,CAS会返回成功还是失败,不会挂起线程。但是如果基于while这种循环操作去调度CAS直到成功,那可能会优点消耗CPU的资源了,一直执行CAS指令,但是一段是时间无法成功。 如果你感觉短期内就能ok,那就上CAS,如果不成,使用悲观锁(synchronized,lock锁)

自旋锁,CAS,乐观锁,自适应自旋锁

乐观锁:是一种泛指,Java有Java的乐观锁实现,MySQL也有自己的乐观锁实现。(不会挂起
线程)
悲观锁:也是一种泛指,认为拿不到资源,拿不到就挂起线程。
CAS:Java中的乐观锁实现,是CAS。CAS对于Java来说,就是一个方法,做一次比较和交换。
(不会挂起线程,线程的状态从运行到阻塞)
自旋锁:你可以自己实现,就是循环去执行CAS,知道成功为止。
while(!cas()){}
for(;;😉{ if(cas) return }
自适应自旋锁: 这个东西就是synchronized的轻量级锁用到了,相对智能的自旋锁,如果上次
CAS成功了,这次CAS循环次数,加几次。如果上次失败了,这次CAS就减几次。

synchronized的实现原理

synchronized应该不陌生,这东西就是JVM层面最原始的互斥锁。
使用方式,就同步代码块,同步方法。
这个是重量级锁的原理。
synchronized因为是互斥锁,只能有一个线程持有当前锁资源。所以synchronized底层有一个owner属性,这个属性是当前持有锁的线程。如果owner是NULL,其他线程就可以基于CAS将owner从NULL修改为当前线程,只要这个CAS动作成功了,就可以获取这个synchronized锁资源。如果失败了,会再尝试几次CAS,没拿到就park挂起当前线程。

synchronized的锁升级过程

在这里插入图片描述
无锁:当前对象没有被作为锁资源存在 && 在JDK1.8中,会有一个4s的偏向锁延迟,这段时间的对象就处于无锁状态。
偏向锁:如果撇去4s的偏向锁延迟,那么刚new出来的对象,基本都是偏向锁。
Ps:如果某一个线程,反复的去获取同一把锁,此时偏向锁的优势就出现了,无需做CAS操作,比较一下指向的是否是当前线程

  • 如果没有被所谓锁资源,那么这个偏向锁,没有偏向某一个线程。哪个线程都没偏向(匿名偏 向)
  • 作为锁资源存在了,同时指向着某一个线程,这个就是偏向锁(普通偏向)

轻量级锁:如果偏向锁状态下,出现了竞争,那么升级为轻量级锁。轻量级锁状态下,会执行多次CAS,默认初始次数是10次,这种CAS是采用的自适应自旋锁。
重量级锁:如果轻量级锁状态下,CAS完毕获取锁失败,直接升级到重量级锁。到了重量级锁的状态下,就是再次基于几次CAS尝试修改owner属性,成功,拿锁走人。 失败,挂起线程。等到其他线程释放锁后,再被唤醒。

一般来说,锁只有升级,没有降级。
但是有点特殊情况,比如偏向锁可以退到无锁。因为偏向锁无法保存对象的hashcode,如果在偏向锁状态,并且没有作为锁的情况,执行了hashcode方法,会从偏向锁到无锁。

下面是JIT优化导致的轻量级锁降级到无锁的状态

public class LockTest {
    public static void main(String[] args) throws Exception {
        synchronizedTest();
    }

    public static void synchronizedTest() throws InterruptedException {
        Thread.sleep(5000);
        Object o = new Object();
        // 00000101 无锁/匿名偏向
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        Thread thread = new Thread(() -> {
            synchronized (o) {
                // 10000010 重量级锁
                System.out.println(Thread.currentThread().getName() + "-1:" + ClassLayout.parseInstance(o).toPrintable());
            }
            // 00000010 重量级锁
            System.out.println(Thread.currentThread().getName() + "-2:" + ClassLayout.parseInstance(o).toPrintable());
            try {
                // 等待锁降级
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 00000001 无锁
            System.out.println(Thread.currentThread().getName() + "-3:" + ClassLayout.parseInstance(o).toPrintable());
            synchronized (o) {
                // 00010000 轻量级锁
                System.out.println(Thread.currentThread().getName() + "-4:" + ClassLayout.parseInstance(o).toPrintable());
            }
        });
        thread.start();
        synchronized (o) {
            // 00000101 无锁/匿名偏向
            System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(o).toPrintable());
        }
        while (thread.isAlive()) {
        }
    }
}

AQS

AQS本质就是个抽象类,AbstractQueuedSynchronizer。AQS是JUC包下的一个基础类,没有具体
的并发功能的实现,不过大多数JUC包下的工具都或多或少继承了AQS去做具体的实现。
比如ReentrantReadWriteLock,ReentrantLock,CountDownLatch,线程池之类的,都用继承
了AQS做自己的实现。

AQS有三个核心点:

  • volatile修饰int属性state。(如果作为锁,state == 0,代表没有线程持有锁资源,如果大于 0,代表有线程持有锁资源)
  • 基于Node对象组成的一个同步队列(如果线程想获取lock锁,结果失败了,会被挂起线程,线程会被封装为Node对象,扔到这个同步队列中)
  • 基于Node对象组成的单向链表(当线程持有锁资源时,如果执行了await,线程会释放锁资
    源,并且将线程封装为Node对象,扔到这个单向链表中。如果其他线程执行了signal,那就会 将单向链表的Node节点扔到同步队列)

ReentrantLock释放锁时为什么要从后往前找有效节点?

在释放锁唤醒排队的Node时,会先找head.next唤醒,如果head.next是取消状态,那么AQS的逻辑是从tail往前找,一直找到里head最近的有效节点。

为什么不从前往后找,更快。
因为节点在取消时,为了更方便GC回收,会做一个操作,将Node的next指针指向自己,形成一个循环引用,这样更容易被GC发现。另外AQS全局是以prev指针为基准的,所有操作都是prev准,next不一定准。

公平锁和非公平锁的区别

语言层面上,区分很简单,就是一个公平,一个不公平。
这个问题最好从源码的维度来聊。
可以扩展说一下,synchronized支持持非公平锁,ReentrantLock既有公平,也有非公平。
在ReentrantLock中,有两个方法的实现有公平和非公平之分。

  • lock方法
    • 非公平锁:直接执行CAS,尝试将state从0改为1,如果CAS成功了,拿锁走人,失败了走 后续逻辑。
    • 公平锁:直接走后续逻辑(后续逻辑包含tryAcquire方法)。
  • tryAcquire方法:
    • 非公平锁:如果state为0,会直接执行CAS,尝试将state从0改为1,如果CAS成功了,拿 锁走人,失败就准备排队。
    • 公平锁:如果state为0,先查看一下,是否有排队的节点,如果有排队的,那就不抢,直接 去排队。

简述一下你对线程池的理解

如果问到了这样的问题,可以展开的说一下线程池如何用、线程池的好处、线程池的启动策略
合理利用线程池能够带来三个好处。
第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
第三:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

参数含义:

corePoolSize:核心线程数 (线程池内部运行起来之后,最少有多少个线程等活。 核心线程是
懒加载 )
maximumPoolSize:最大线程数 (当工作队列堆满了,再来任务就创建创建非核心线程处
理。)
keepAliveTime:最大空闲时间 (默认非核心线程,没活之后,只能空闲这么久,时间到了,
干掉)
unit:空闲时间单位(上面时间的单位)
workQueue:工作队列 (当核心线程数足够后,投递的任务会扔到这个工作队列存储。
LinkedBlockingQueue)
threadFactory:线程工厂(构建线程的,根据阿里的规范,线程一定要给予一个有意义的名
字,方便后期排查错误)
handler:拒绝策略 (核心数到了,队列满了,非核心数到了,再来任务,走拒绝策略……)

线程处理完任务之后,会在工作队列的位置执行take(无线等) / poll (指定等待的时间),等任务!
非核心线程创建完,是处理传递过来的任务,不会优先处理工作队列的任务!
线程池在创建工作线程时,会区分核心线程和非核心线程,但是线程开始干活后,他就不区分了,只保证数值足够即可。

如何设置参数
为了配置核心线程数,需要观察两个内容:

  • 硬件的CPU内核数。
  • 业务类型
    • CPU密集。
    • IO密集。
    • 混合型(一半CPU,一半IO)

考虑CPU密集型,其实所谓CPU密集就是需要CPU一直调度当前线程,当前线程做的业务大多数是计算啊,数据转换等等,不会出现阻塞的情况。 这种情况,以经验之谈来说,核心线程就设置CPU内核数 ± 1即可。但是由于CPU厂商不同,性能不同,加上服务器的操作系统区别,CPU内核数 ± 1不一定是最佳的,需要一定的压测得出一个合理的数值。 jmeter压测即可。

考虑IO密集型,什么叫IO密集呢,比如你的业务涉及到了大量的查询数据库,查询三方服务获取一些数据,而查询数据的时候,线程基本都处理阻塞状态。 这种查询三方服务或者是数据库的操作,可能会因为三方服务的网络抖动,或者查询数据库走没有索引之类的,对阻塞时间有一些影响,此时你会发现,IO密集型的方式,好像没有什么特别好的公式可以直接用。

想获取合理的数值,你可以优先根据IO密集和CPU密集大致得出一个核心线程数,基于这个数值去做压测,根据测试的结果,你可以调大核心线程数,再测,调小核心线程数再测,直到得出一个效率最高的数值。
压测的过程中,需要动态修改线程池中的参数,而线程池恰恰可以做到动态的修改,只需要执行set方法即可,可以自行实现。也可以用一些三方的开源框架,基于美团的动态线程池策略开源的一个线程池监控工具Hippo4j。

最大线程数: 其实核心线程数已经可以做到尽可能的发挥CPU的性能了,所以最大线程数最好设置为跟核心线程数一致。如果在核心线程的基础上,又多追加了几个线程,反而会导致性能下降~~ 最大线程数 = 核心线程

工作队列: 是任务排队的地方,很多任务会扔到这个队列中排队,等待线程执行。
每个任务都是Runnable的实现,是一个对象,对象要占用堆内存空间。不能让排队的任务压爆JVM内存。
任务扔到工作队列,需要等待排队处理,你可以考虑排在最后面的任务需要多久才能处理到。再根据你业务允许的延迟时间考虑,你的工作队列要多长。

拒绝策略: 当工作队列满了,如果最大线程数 = 核心线程,那就要走拒绝策略了。
如果你的任务是个记录日志啊这种丢弃也无所谓的任务,那就扔了呗~~~
如果你的任务是核心业务线必备的一环,那就不能扔,你可以让业务线程处理,你也可以把任务留存好,做最终一致性。
一般到这了,性能到瓶颈,喊领导,上服务器,加钱

线程池工作原理:

提交一个任务到线程池中,线程池的处理流程如下:

  1. 判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。
  2. 线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  3. 判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

在这里插入图片描述
拒绝策略
线程池自己提供了4种拒绝策略,供咱们使用,如果这4个不够,也可以自己去实现拒绝策略提供了一个接口规范,让咱们去实现。
在这里插入图片描述

AbortPolicy:扔异常。
DiscardPolicy:任务直接丢弃。
CallerRunsPolicy:谁投递的任务,谁自己处理。
DiscardOldestPolicy:将队列中排在最前面的任务干掉,尝试将自己再次投递到线程池

线程池里的工作线程和普通线程有什么区别
本质上没任务区别,因为你想在Java创建一个线程,你能用到的方式只有一个,thread.start();
但是,线程池中的工作线程,是因为他封装了一个Worker对象。这个Worker对象为了适配线程池的逻辑,他实现了Runnable存储第一次要执行任务。其次还继承了AQS,为了满足线程池的shutdown和shutdownNow的逻辑。

shutdown和shutdownNow 都是关系线程池的方法。最终会把工作线程全部干掉。
shutdown方法: 等工作队列的任务全部处理完,等待任务的线程直接中断,再干掉工作线程。
shutdownNow方法: 立即中断正在处理的任务,等待任务的线程直接中断,同时把工作队列中的任务全部返回,再干掉工作线程。

工作线程在执行任务前,会先基于AQS将state设置为1,代表当前工作线程正在干活,干完活之后,会将state设置为0。shutdown方法不会中断正在处理任务的线程,所以shutdown中断线程前,先查看state是几。

如果主线程结束了,没shutdown线程池,线程池会一直存在吗?
会,因为线程不会回收,run方法没结束,线程是Worker类,Worker是线程池的内部类,线程池
在,线程在,导致内存泄漏。

public void xxx(){
    ThreadPoolExecutor executor = new ThreadPoolExecutor();
    // 基于线程池做业务处理
    executor.shutdown();
}

如何在线程池执行任务前后追加一些操作
线程池底层在执行任务前后,提供了两个勾子函数/扩展口,你可以继承线程池对这两个方法重写,那么以后只要线程池执行任务前后,都会执行这两个勾子函数。

public class MyTP extends ThreadPoolExecutor {
    public MyTP(int corePoolSize,
                int maximumPoolSize,
                long keepAliveTime,
                TimeUnit unit,
                BlockingQueue<Runnable> workQueue,
                ThreadFactory threadFactory,
                RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
    }

    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        // 任务执行前干点啥。
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        // 任务执行后干点啥。
    }
}

任务饥饿问题
线程池会确保一个事情,只要没走shutdownNow,就必须保证在工作队列有任务时,至少要有一个工作线程存在,去处理工作队列中的任务。核心线程可以设置为0个。

工作线程结束的方式
正常结束就是run方法结束,可以是非核心线程的时间到了,run方法正常结束。也可以是执行任务期间,出现了异常。如果是Runnable的方式提交任务,那工作线程就异常结束,工作线程就没了。但是如果提交任务的方式是基于Callable走的FutureTask,那执行过程中的异常不会抛出来,工作线程不会结束。

场景线程池
在这里插入图片描述
1、异步要处理的,比如你发送个邮件,发送个短信,这种异步处理的,就上线程池。
2、定时任务,时间到了,触发一个线程去执行某个任务,也能上线程池,而且JUC还提供了
ScheduleThreadPoolExecutor,就是定时任务的线程池。
3、访问多个服务做并行处理,提升效率
4、处理的数据体量比较大,做导入导出这种,可以上多线程做并行处理提升处理效率。
5、框架底层都有线程池,只是你没配置,RabbitMQ的消费者,你不配置线程的信息,他就是单线
程处理,速度嘎嘎慢,你配置了,那就是多个消费者并行处理。

ConcurrenHashMap

存储结构

HashMap和ConcurrenHashMap在存储结构上是一模一样的。
数组 + 链表 + 红黑树。
在这里插入图片描述
点了几个东西:

  • 存储结构
  • 红黑树出现的原因
  • 链表何时转换为红黑树 为什么链表长度为8才转红黑树
  • 红黑树结构情况下,如果删除元素,导致红黑树元素个数小于等于6,会退化为链表。
  • 数组扩容触发的两种情况

保证写操作线程安全的方式

注意这里需要区分JDK1.8 和1.7版本
在这里插入图片描述
数组上扔数据,CAS保证安全。
链表/红黑树扔数据,synchronized锁数组元素保证线程安全。

JDK1.7中的ConcurrentHashMap是基于分段锁来保证的线程安全。
在这里插入图片描述

计数器实现

ConcurrentHashMap用的LongAdder来保证线程安全的。
LongAdder底层就是基于CAS的方式,在做+1和-1操作,自然能保证线程安全。
但是AtmoicLong也能保证线程安全,为啥不用AtmoicLong呢?
比如ConcurrentHashMap中记录元素个数的是baseCount,如果有大量线程都想修改baseCount,基于CAS的方式,每次并发只会有一个线程成功,其他失败的线程需要再次获取baseCount的值,再执行CAS………
AtmoicLong就是这种方式,会导致性能变慢,而且空转的CAS操作,会浪费CPU的性能资源。

LongAdder解决上述问题的方式很简单,不让每个线程都对baseCount做CAS操作,LongAdder中提供了很多的CounterCell对象,每个CounterCell内部都有一个long类型的value,线程在做计数时,可以随机选择一个CounterCell对象对内部的value做+1操作。CounterCell数组的长度最长和你的CPU内核数一致。 CAS是CPU密集操作,和CPU内核数 ± 1 正好,baseCount + 所有CounterCell对象的value,最终结果是ConcurrentHashMap中的元素个数。

扩容大致流程

  1. 扩容触发时机
    • 链表到8。数组长度小于64,扩容数组。
    • 0.75的负载因子,元素个数到了,就得扩。
    • 执行putAll时,如果putAll中的map元素个数当前map无法放下,那就优先扩容。(跟0.75有关系)将map.size做好运算,与当前的扩容阈值做比较,如果小于扩容阈值,直接添加,大于扩 容阈值,那就优先扩容。
  2. 计算扩容标识戳
    • 标识戳后面会作为标记,代表当前ConcurrentHashMap内部正在扩容数组。
    • 标识戳会记录当前是从多少长度的数组开始做扩容的,避免协助扩容时,出现错误。
  3. 计算每次迁移数据的步长,基于数组长度和CPU内核数计算,最小是16
    每个线程会先领取一定长度的迁移数据的任务,领取完,一个位置一个位置的迁移。每次领取任务的长度是多少,就基于步长来做的。
  4. 创建新数组,长度是老数组的二倍。
  5. 领取迁移数据的索引位置的任务,基于步长得出从哪个索引迁移到哪个索引。
  6. 开始将老数组数据迁移到新数组,等老数组的某个索引位置迁移完之后,会留下一个标记,标记代表当前位置数据全部迁移到了新数组。
  7. 等老数组的所有数据,都迁移到新数组上之后,最后一个完成迁移数据的线程,会整体再检查一遍老数组中有没有遗留的数据在。(基本没有)
  8. 最后检查完毕之后,迁移结束

获取数据

ConcurrentHashMap在维护红黑树的同时,还会保留一个双向链表的数据结构。
ConcurrentHashMap的读操作,永不阻塞!

  1. 如果数据在数组上,查询到直接返回。
  2. 如果数据在链表上,找到数组的索引位置后,next,next一个一个往下找,找到返回。
  3. 如果数据在红黑树上
    • 如果有写线程在红黑树上写数据,那么读线程去读取一个双向链表查询数据。
    • 如果没有写线程在操作红黑树,那就在红黑树上正常的left,right去找对应数据。
  4. 如果定位的索引位置是一个标记(扩容的那个标记)
    直接基于标记定位到新数组的位置,去新数组找数据。

CountDownLatch的运用

CountDownLatch就是一个计数器。这个计数器是你指定好数值,比如你指定3,每次执行
countDown就-1,见到0之后,任务处理完毕。

// 这个就是信号量,有10个资源,每个线程拿一个资源,才能去做某个操作。
@SneakyThrows
public static void findBy三方(){Thread.sleep(700);}
@SneakyThrows
public static void findByMySQL(){Thread.sleep(200);}
@SneakyThrows
public static void findByB服务(){Thread.sleep(300);}
public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    CountDownLatch count = new CountDownLatch(3);
    executor.execute(() -> {
        findBy三方();
        count.countDown();
    });
    executor.execute(() -> {
        findByMySQL();
        count.countDown();
    });
    executor.execute(() -> {
        findByB服务();
        count.countDown();
    });
    try {
        count.await(1, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
        // 超时了~~~
    }
    // 执行到这,代表三个操作全部反正,做汇总响应
}

其次,有一个JUC工具,叫CyclicBarrier,这个东西和CountDownLatch挺像,但是有一点不一样。
CountDownLatch减到0就没啥用了,不能复用。
而CyclicBarrier也是业务线程等待其他线程处理完,再继续执行,但是CyclicBarrier可以重置

@SneakyThrows
public static void findBy三方(){Thread.sleep(700);
    System.out.println("查询完三方");}
@SneakyThrows
public static void findByMySQL(){Thread.sleep(200);System.out.println("查询完MySQL");}
@SneakyThrows
public static void findByB服务(){Thread.sleep(300);System.out.println("查询完B服务");}
public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    CyclicBarrier cyclicBarrier = new CyclicBarrier(3,() -> {
        // 执行到这,代表三个操作全部反正,做汇总响应
        System.out.println("全完了。");
    });
    executor.execute(() -> {
        findBy三方();
        try {
            cyclicBarrier.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 再做其他操作
    });
    executor.execute(() -> {
        findByMySQL();
        try {
            cyclicBarrier.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 再做其他操作
    });
    executor.execute(() -> {
        findByB服务();
        try {
            cyclicBarrier.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 再做其他操作
    });
    // 可以重置再使用
    cyclicBarrier.reset();
}

Semaphore的运用。

一般用于限流比较多一些。
如果当前某个操作要做限流,比如最多有10个线程并行执行这个操作。
那就可以用Semaphore。

static Semaphore semaphore = new Semaphore(10);
/**
 * 最多10个线程并行玩。
 */
public static void 某个操作(){}
public static void main(String[] args) throws Exception {
    boolean b = semaphore.tryAcquire(1000, TimeUnit.MILLISECONDS);
    if(b){
        try {
            某个操作();
        } finally {
            semaphore.release();
        }
    }else{
        // …………
    }
}
  • 15
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值