JUC面试点汇总
阅读原文时间:2023年07月09日阅读:1

JUC面试点汇总

我们会在这里介绍我所涉及到的JUC相关的面试点内容,本篇内容持续更新

我们会介绍下述JUC的相关面试点:

  • 线程状态
  • 线程池
  • Wait和Sleep
  • Synchronized和Lock
  • Volatile线程安全
  • 悲观锁和乐观锁
  • Hashtable和ConcurrentHashMap
  • ThreadLocal

下面我们来介绍我们面试中经常考察的两种线程状态分类

六种线程状态

Java虚拟机将线程状态划分为六种:

我们来简单介绍一下:

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后
  • 注意,Java API 层面的 RUNNABLE 状态涵盖了操作系统层面的可运行状态运行状态和阻塞状态
  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对阻塞状态的细分
  • BLOCKED 表示被锁拦截下的阻塞
  • WAITING 表示自己进行wait等待时的阻塞
  • TIMED_WAITING 表示进行有时间限制的阻塞
  • TERMINATED 当线程代码运行结束

五种线程状态

操作系统将线程分为五种状态:

我们来简单介绍一下:

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入 【阻塞状态】
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑 调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

下面我们来介绍线程池中常考的一些知识点

线程池工作流程图

首先我们给出线程池的工作流程图以及相关参数:

我们对上面元素进行简单介绍:

  • submit(task):负责分配任务,将任务传入WorkQueue
  • WorkQueue:工作等待队列,用于存放未被执行的任务,通常具有任务个数限制,防止内存过满
  • 核心线程:一直处于运行状态的线程,不断从WorkQueue中取得任务并执行
  • 救济线程:只有当核心线程全部运行且WorkQueue装载满员并且有submit继续传入任务时开启,在一定时间没有任务接收后结束

线程池工作参数

我们给出线程池工作的基本参数:

/*corePoolSize核心线程数目*/

用于控制核心线程的个数

/*maximumPoolSize最大线程数目*/

表示核心线程和救急线程的最大个数,用该值减去corePoolSize核心线程数目就是救急线程个数

/*keepAliveTime生存时间*/

针对救急线程,当救急线程在该时间段没有接收新任务,就结束该线程

/*unit时间单位*/

配合keepAliveTime生存时间使用的时间单位

/*workQueue阻塞队列*/

用于存放处于阻塞状态的任务

/*threadFactory线程工厂*/

用于生成线程名称

/*handler拒绝策略*/

当线程均处于运行状态,workQueue满员,且有新任务进入时,handler负责处理新进入的线程

- AbortPolicy 让调用者抛出 RejectedExecutionException 异常,这是默认策略
- CallerRunsPolicy 让调用者运行任务
- DiscardPolicy 放弃本次任务
- DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之
- Dubbo 的实现,在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息,方 便定位问题
- Netty 的实现,是创建一个新线程来执行任务
- ActiveMQ 的实现,带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
- PinPoint 的实现,它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

线程池构建代码

我们直接给出线程池构建代码,了解即可:

/*线程池构造:我之前有线程池专门的文章,可以深入了解*/

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

我们来介绍一下wait和sleep的相关面试点

Wait和Sleep区别以及共同点

我们给出wait和sleep的区别以及共同点:

/*共同点*/

wait(),wait(long),sleep(long) 都会导致阻塞,将当前线程暂时放弃CPU使用权

/*不同点*/

// 方法归属类不同
wait     属于  Object实体类
sleep     属于  Thread线程类

// 醒来时机不同
wait():            只有当notify唤醒时才会醒来
wait(long):     当时间结束,或者notify唤醒就会醒来
sleep(long):    当时间结束才会醒来

注意:都可以被打断唤醒!

// 锁性质不同
1.wait必须配合锁一同使用,且只有锁对象调用Lock.lock;sleep在任何时间段都可以使用
2.wait在lock时会解除当前锁的限制;sleep若在锁中不会解除当前锁的限制

我们来介绍一下Synchronized和Lock的相关面试点

Synchronized和Lock区别以及共同点

我们会从三个层面讲解两者的区别以及共同点:

/*语法层面*/

// 所属类
synchronized:关键字,属于JVM,用C++语言实现
Lock:接口,属于JDK,用Java语言实现

// 锁实现
synchronized:在结束同步代码块后自动释放锁
Lock:在结束代码块后,需要手动unlock释放锁

/*功能层面*/

// 相同点
均属于悲观锁,具备互斥,同步,锁重入功能

// 不同点
Lock提供了synchronized所不具备的功能:获得等待状态,公平锁,可打断,可超时,多条件变量
Lock提供了多场景Lock:ReentrantLock,ReentrantReadWriteLock

/*性能方面*/

// 性能差距
synchronized:在无竞争情况下,存在轻量级锁,偏向锁,性能较高
Lock:在竞争激烈的情况下,性能更好

相关知识点补充

我们来补充上述所讲述的部分知识点:

/*知识点补充*/

// Lock正常使用

owner:正在运行程序,存有status,表示几重锁,当锁重入时,status++;解除锁时,status--;当status==0,释放锁
blocked queue:阻塞队列
waiting queue:等待队列

// 公平锁和非公平锁

公平锁:所有任务在进入时均放于阻塞队列按顺序排序并执行
非公平锁:当owner释放锁后,新加入的任务可以和阻塞队列的任务处于同一级竞争锁

// 多条件变量
条件变量创建:Condition c1 = Lock.newCondition("c1");
条件变量使用:c1.await();
条件变量唤醒:c1.signal();
条件变量唤醒:c1.signalAll();

必须处于owner才能使用await,唤醒后放于Blocked Queue尾部等待

我们来介绍一下Volatile的线程安全相关问题

Volatile线程安全

我们首先要知道线程安全主要从三方面解释:

/*可见性*/

当前线程对数值的修改是否对其他线程可见?

问题产生原因:
    CPU和内存之间还有一层缓存区,当使用数据较多时,会直接将内存的数据放入缓存区,然后直接从缓存区调入数据
    这时倘若另一个线程修改了内存中的数据,但是原线程仍旧从自己的缓存区读取数据,就会导致数据不可见

/*有序性*/

当前线程内的代码是否按照编写顺序执行?

问题产生原因:
    JVM存在自动编写机制,当出现同级别的代码时,JVM会自动优化代码顺序,加快速度,可能导致代码执行顺序错乱

/*原子性*/

当前线程内的代码是否为一次执行?

问题产生原因:
    线程是由CPU调度使用的,倘若线程调度到达指定时间片,可能就会导致线程内代码未完成执行而被其他线程使用的情况

然后我们需要知道Volatile对这三种特性的可控度:

/*可见性*/

Volatile可以保证数据的可见性

Volatile会使该属性的每次读取都默认从内存中读取(该属性不会被存放于缓冲区)

/*有序性*/

Volatile可以保证数据的有序性

Volatile存在写屏障和读屏障
    写屏障出现在属性输入之后,在写屏障之前的代码顺序不会变更
    读屏障出现在属性读取之前,在读屏障之后的代码顺序不会变更

/*原子性*/

Volatile无法保证数据的原子性!

我们来介绍一下悲观锁和乐观锁的面试点

悲观锁和乐观锁区别

我们来介绍一下悲观锁和乐观锁的区别:

/*悲观锁*/

代表:
    Synchronized
    Lock

特点:
    1.核心思想:当前线程占用锁后,才能操作共享数据,只有当当前线程结束操作后,其他线程才能竞争
    2.线程的运行与阻塞都会导致上下文切换,频繁的上下文切换会导致CPU速度降低
    3.悲观锁大部分都存在自旋现象,在获得锁时会多次尝试来减少上下文切换次数

/*乐观锁*/

代表:
    Atomic系列
    AtomicInteger

特点:
    1.核心思想:所有数据都可以操作共享数据,但只有一个线程可以修改数据,其他线程如果修改失败就会不断尝试
    2.由于线程一直运行,不需要阻塞,不涉及上下文切换
    3.由于线程一直运行,需要一个CPU保证其线程的活动,否则单CPU下乐观锁属于负增益,一般线程数不会超过CPU个数

悲观锁乐观锁代码比较

我们分别给出悲观锁和乐观锁的代码展示:

/*乐观锁底层实现*/

// 乐观锁底层其实是采用Unsafe类来完成的

// 1.获得该类的属性对于类的偏移量(第一个参数:类名称.class,第二个参数:类属性名称)
Long BALANCE = unsafe.objectFieldOffset(Account.class,"blance");

// 2.采用unsafe的原子比较赋值方法(第一个参数:类对象,第二个参数:属性偏移量,第三个参数:修改前数值,第四个参数:修改后数值)
// 如果再次检测时,该值和oldInt相同,就将其修改为newInt,否则不作为
unsafe.compareAndSetInt(account,BALANCE,oldInt,newInt);

/*乐观锁代码*/

Thread t1 = new Thread(() -> {
    // 乐观锁需要不断尝试
    while(true){
        // 每次获得当前值
        int oldInt = account.getBalance;
        // 我们假设做++操作
        int newInt = oldInt + 1;
        // 然后启动unsafe的比较赋值(compareAndSetInt会返回一个布尔值表示是否成功),若成功退出循环
        if(unsafe.compareAndSetInt(account,BALANCE,oldInt,newInt)){
            break;
        }
    }
}).start;

/*悲观锁代码*/

Thread t2 = new Thread(() -> {
    // 悲观锁就是直接采用锁处理即可
    synchronized(Account.class){
        int oldInt = account.getBalance;
        int newInt = oldInt + 1;
        account.setBalance(newInt);
    }
})

我们来介绍一下Hashtable和ConcurrentHashmap的面试点

Hashtable和ConcurrentHashmap区别

我们来介绍一下Hashtable和ConcurrentHashmap区别:

/*线程安全?*/

Hashtable和ConcurrentHashMap均属于线程安全类的Map集合

/*并发度*/

Hashtable:只存在一个锁,所有索引点的操作均在一个锁上,并发度低

1.7ConcurrentHashMap:底层由数组+Segment+链表结构,每个Segment对应一把锁,不同Segment不会造成锁冲突

1.8ConcurrentHashMap:底层由数组+链表结构,每个链表头对应一把锁,相当于每个索引点对应一把锁,只有同一条链表会产生锁冲突

Hashtable

我们来介绍一下Hashtable的基本面试点:

/*基本问题*/

初始capacity:11

扩容:超过0.75

索引:hashcode即可,(因为以质数为主,分散性较好,不需要二次hash)

1.7ConcurrentHashMap

我们来介绍一下JDK1.7版本的ConcurrentHashMap:

/*基本组成*/

capacity:总共的索引头

factor:超过0.75

clevel:并发度,也就是Segment的个数

每个Segment算是一个大桶,然后大桶中会根据capacity/clevel算出小桶

举例:
    capacity:32
    factor:0.75
    clevel:8

    这时存在8个Segment,每个Segment中存有四个初始小桶

    不同Segment拥有不同锁,不同Segment独自占有并发性

    基本形式如下:
    ---- ---- ---- ---- ---- ---- ---- ----

/*put操作*/

1.hashCode

2.hash

3.根据hash值二进制的前(clevel二进制为2的n次方)n位的数值来判断放在哪个大桶

4.根据hash值二进制的后(小桶大小二进制为2的n次方)n为的数值来判断放哪个大桶

举例:
    capacity:32
    factor:0.75
    clevel:8

    首先clevel的为2的3次方,小桶大小为2的2次方

    假设我们的hash值二进制为 110 1101 0110

    这时我们的大桶取前三位:110 -> 6 -> Segment[6]

    这时我们的小桶取后两位: 10 -> 2 -> Segment[6][2]

    也就是第七个桶的第三个位置

/*扩容*/

扩容仅针对每个Segment单独扩容,最开始的桶大小为capacity/clevel,当超过factor,就会自动扩大一倍,单独计算

Segment[0]的扩容不会影响到其他Segment的桶大小

/*Segment[0]*/

Segment[0]会自动初始化小桶,其他Segment只有在put第一个数时初始化

因为Segment[0]类似于一个初始模板,其他Segment会根据Segment[0]的大小来构造,节省空间(懒汉式构建)

1.8ConcurrentHashMap

我们来介绍一下JDK1.8版本的ConcurrentHashMap:

/*基本知识点*/

1.8版本的ConcurrentHashMap只包含 数组 + 链表 结构

属于懒汉式初始化,我们new一个ConcurrentHashMap并不会产生数组,只有开始put时才会初始化

capacity:16
    注意:这里的capacity并不是初始桶大小,而是我们需要插入的数的数量,系统会根据我们书写的capacity更换桶大小
    例如我们写15,系统会为我们分配一个大小为32的桶

factor:达到0.75

/*并发依据*/

1.8ConcurrentHashMap根据链表头分配不同的锁,也就是如果不是在同一索引下,均可以正常运行

/*扩容操作*/

扩容是ConcurrentHashMap的考点之一

ConcurrenthashMap扩容是从后往前移动数据,每次移动完成该索引点数据,就为其标记为ForwardingNode用来表示已移动

ConcurrentHashMap扩容不再是将原数据next更换,而是直接在新数组上创建新数据,将数据拷贝过去,防止并发操作时出现问题

/*扩容细节*/

当一个线程t1正在进行扩容,另一个线程t2参与该HashMap各项操作:

1.get操作:
    I.如果查询的该索引属于ForwardingNode,就去新的数组中查找
    II.如果查询的索引不属于ForwardingNode,就直接查找

2.put操作:
    I.如果该索引不属于ForwardingNode,就直接插入即可
    II.如果该索引正在迁移,堵塞
    III.如果该索引已经属于ForwardingNode,帮助线程t1完成扩容后,再进行修改

我们来介绍一下ThreadLocal的面试点

对ThreadLocal的理解

我们来讲解一下对对ThreadLocal的理解:

/*线程安全性*/

ThreadLocal可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题

ThreadLocal同时实现了线程内资源共享

/*ThreadLocal使用*/

每个线程中有一个ThreadLocalMap类型的成员变量,用于存储资源对象

1.创建一个ThreadLocal(该ThreadLocal实际上就是ThreadLocalMap的key)
    ThreadLocal<class?> t1 = new ThreadLocal<>();

2.可以调用set方法存储数据(ThreadLocal就是key,资源对象作为value,放入当前线程的ThreadLocalMap集合中)
    t1.set(new String("123"));

3.可以调用get方法查找数据(根据ThreadLocal为key,查找相关数据)
    t1.get();

4.可以调用remove方法删除该数据(根据ThreadLocal为key,删除相关数据)

注意点:
    I.当使用set时才会构造map对象
    II.不同的ThreadLocal使用不同的数据结构
    III.扩容时阈值为0.75,每次扩容一倍;存储时采用开放寻址法

/*key*/

这里的key采用的是弱引用:
    1.Thread可能需要长时间运行(如线程池的线程),如果key不再被使用,可以被JVM的GC所释放
    2.GC仅使key释放,但是value不会释放:
        I.获得key时,会删除value
          II.setkey时,会将附近的value删除
        III.手动remove删除

结束语

目前关于JUC的面试点就总结到这里,该篇文章后续会持续更新~

附录

参考资料:

  1. 黑马Java八股文面试题视频教程:并发篇-01-线程状态_java中的线程状态_哔哩哔哩_bilibili