JUC-多线程(11.面试问题简析)学习笔记

文章目录

  • 1. synchronized 关键字底层原理以及其与 lock 的区别
  • 2. 对CAS的理解以及底层实现原理
  • 3. ConcurrentHashMap实现线程安全的底层原理是什么
  • 4.对JDK中的AQS了解吗?AQS的实现原理是什么?
  • 5. 线程池的核心配置参数是干什么的?应该怎么用?
  • 6. 线程池的底层工作原理
  • 7. 如果在线程池中使用无界阻塞队列会发生什么问题?等同于问,在远服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
  • 8. 线程池的队列满了之后,会发生什么?
  • 9. 如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办
  • 10. 对JAVA内存模型的理解
  • 11. JAVA内存模型中的原子性、有序性、可见性
  • 12. 从JAVA底层角度聊聊volatile关键字的原理
  • 13. 指令重排以及happens-before原则
  • 14.volatile底层是如何基于内存屏障保证可见性和有序性的?

1. synchronized 关键字底层原理以及其与 lock 的区别

  1. synchronized 基本使用

    // synchronized (对象/类) 对 一个对象/一个类的Class对象 进行加锁
    synchronized (Object{   
        // 需要被同步(上锁)的代码;
    }
    

    最主要的作用:被修饰的对象在任一时刻只能由一个线程访问。

  2. 如果使用到了 synchronized 关键字,在底层编译后的 JVM 指令中,会有 monitorenter 和 monitorexit 两个指令

    • monitor

      • 每个对象都有一个关联的 monitor,比如一个对象实例就会有一个 monitor,一个类的 Class 对象也会有一个 monitor ,如果要对这个对象加锁,那么必须获取这个对象关联的 monitor 的 lock 锁
      • monitor 的锁是支持重入加锁的,即对同一个对象重复的加锁,例如以下示例
      synchronized (myObject){   
      	// 需要被同步(上锁)的代码;
      	synchronized (myObject){   
      		// 需要被同步(上锁)的代码;
      	}
      }	
      
    • monitorenter (加锁指令)执行的时候会干什么呢

      • 原理和思路大致是:monitor 里面有一个计数器,从 0 开始的,如果一个线程要获取 monitor 的锁,就看看他的计数器是不是 0 ,如果是 0 则说明没有人获取锁,那就可以获取锁了,然后对计数器 +1 (如果遇到上述所说的重复加锁,则会继续+1)
      • 如果此时有另一个线程来获取锁,发现计数器不为 0 ,则获取锁失败,陷入阻塞等待状态
    • monitorexit 释放锁指令

      • 如果出了 synchronized 修饰的代码片段的范围,就会执行该指令。此时获取锁的线程就会对那个对象的 monitor 计数器 -1,如果存在重复加锁的情况,则对应多次 -1,直到计数器为 0 。
  3. synchronized 与 lock 的区别
    JUC-多线程(11.面试问题简析)学习笔记_第1张图片

2. 对CAS的理解以及底层实现原理

  1. 首先说一下 CAS 解决的问题

    • 当多个线程对同一个数据进行操作的时候,如果没有同步就会产生线程安全问题。
    • 为了解决线程线程安全问题,我们需要加上同步代码块,如加上synchronized。但是某些情况下这并不是最优选择。
    • synchronized关键字会让没有得到锁资源的线程进入 blocked 状态,而后在争夺到锁资源后恢复为 runnable 状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。并且这个过程是一个串行的过程,效率很低。尽管JAVA 1.6为synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过过度,但是在最终转变为重量级锁之后,性能仍然比较低。
    public class MyObject(){
    	int i = 0;
    	// 多个线程来执行这段代码时,存在线程安全问题,可以加 synchronized
    	public synchronized void increment(){
    		i++;
    	}
    }
    
    • 所以面对这种情况,我们就可以使用java中的原子操作类 ,原子类的底层就是基于 CAS 实现的
    public class MyObject(){
    	AtomicInteger i = new AtomicInteger(0);
    	// 此时可以不需要加 synchronized,也是线程安全的
    	public void increment(){
    		i++;
    	}
    }
    
  2. CAS 介绍

    • CAS是英文单词Compare and Swap的缩写,翻译过来就是比较并替换。
    • CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
    • 更新一个变量的时候,会先比较 ”旧值 A“ 与 ”当前地址 V 中存储的值“ 是否相等,若相等则用 ”新值 B" 替换 “旧值 A”;若不相等则 CAS 失败,将重新执行 CAS。
    • CAS 在底层硬件级别保证一定是原子的,同一时间只有一个线程可以执行 CAS,先比较再替换,此时其他同时进入的线程执行 CAS 会失败
    • 从思想上来说,synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。
  3. CAS 的问题

    1. CPU开销过大
      在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。
    2. 不能保证代码块的原子性
      CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。
    3. ABA问题
      这是CAS机制最大的问题所在。
    • ABA问题,简单来说就是三个线程,修改同一个变量 i, i 初始值为 A:

      线程1:期望将 i 修改为 B
      线程2:期望将 i 修改为 B
      线程3:期望将 i 修改为 A

      此时,线程1、2、3 依次执行,正常情况下最终 i 应该为 A

      线程1成功将i改为了A
      线程2因为某些原因阻塞了
      线程3 紧接着访问,将 i 修改为 B
      此时线程2 继续执行,又将

3. ConcurrentHashMap实现线程安全的底层原理是什么

  1. ConcurrentHashMap 存在的必要性

    如果使用 synchronized 对整个 HashMap 进行加锁,如果操作的是同一个元素还好,如果操作的是不同桶位的元素显然就没什么必要了,还会造成效率的下降
    故 JDK 推出了,ConcurrentHashMap 默认实现了线程安全,在使用时就不需要额外加锁了,直接使用即可

  2. 如何实现

    • JDK 1.8 以前,采用的是将整个数组拆分成多个子数组,分段加锁,一个数组一个锁
    • JDK 1.8 及以后,锁粒度的细化,采用分段加锁:一整个数组,针对每个元素都有一个对应的锁。
      • 同一时间,多个线程,向相同位置进行 put 操作时,采用的是 CAS 策略,并且只有一个线程能成功执行 CAS,其他线程都会失败。
      • 如果失败说明有其他线程在操作了。此时 synchronized 对数组元素加锁。例如同时对 数组[5] 进行put,那么就会执行 synchronized(数组[5]) 进行加锁,基于链表或者红黑树在当前位置插入自己的数据。
      • 同一时间,多个线程,向多个不同位置的元素进行 put 操作,则互不影响。

4.对JDK中的AQS了解吗?AQS的实现原理是什么?

  • 多线程同时访问一个共享数据可以用 sychronized,CAS,ConcurrentHashMap(并发安全的数据结构),以及Lock

  • Lock 他的底层基于AQS技术。Abstract Queued Synchronizer简称为AQS,抽象队列同步器。

    // 创建非公平锁
    ReentrantLock lock = new ReentrantLock();
    // 创建公平锁
    ReentrantLock lock = new ReentrantLock(true);
    // 加锁
    lock.lock();
    // 释放锁
    lock.unlock();
    
  • 原理示意图JUC-多线程(11.面试问题简析)学习笔记_第2张图片

    • AQS 的底层也用到了 CAS。
    • 线程1、线程2 同时获取锁时,会使用 CAS 修改 state 字段的值。
    • 线程1 加锁成功,则会将到加锁线程指向线程1
    • 线程2 加锁失败,则会被加入到等待队列中
    • 当线程1执行完毕,会将state改回0,并将加锁线程置空,并且唤醒等待队列的头节点,重复上面的流程

    上述是一个基本流程,AQS 分为 公平锁 和 非公平锁(默认使用),在上述流程基础上:

    • 非公平锁:当线程1,执行完毕唤醒线程2时,又来了一个线程3,这时线程2 和 线程3 继续争抢锁,若线程3 加锁成功,则线程2继续等待。此种情况不会按照先来后到。
    • 公平锁:当线程1执行完,线程3进来了,会发现等待队列中还有线程2 ,线程3则会进入等待队列,按照等待队列顺序进行加锁。

5. 线程池的核心配置参数是干什么的?应该怎么用?

  • 为了避免频繁的创建、销毁线程,会构建一个线程池,有一定数量的线程,线程执行完任务之后,不要销毁自己,继续等待执行下一个任务。

  • 线程池优点:

    1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
    2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
    3. 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,
  • threadPoolExecutor 7 个重要参数
    JUC-多线程(11.面试问题简析)学习笔记_第3张图片

  1. int corePoolSize
    • 线程池中的常驻核心线程数,最少存在的线程数
  2. int maximumPoolSize
    • 线程池中能够容纳同时执行的最大线程数,此值必须大于等于1

    • 如果是 CPU 密集型(CPU 用的最多):maximumPoolSize(最大线程数) = CPU核数 + 1

      // 获取电脑的 CPU 核数
      int CPU = Runtime.getRuntime().availableProcessors();
      int maximumPoolSize = CPU + 1;
      
    • 如果是 IO 密集型 : maximumPoolSize(最大线程数)= CPU核数 / 阻塞系数

  3. long keepAliveTime
    • 多余的空闲线程的存活时间
    • 当前池中线程数量超过corePoolSize时,且当空闲时间达到keepAliveTime时,多余线程会被销毁直到只剩下corePoolSize个线程为止
  4. TimeUnit unit
    • keepAliveTime的单位 —— 秒 / 毫秒 / 微秒
  5. BlockingQueue workQueue
    • 任务队列,被提交但尚未被执行的任务
    • 线程池的最多可以容纳数 =【最大线程数(maximumPoolSize) + 任务队列(workQueue)可容纳数】
  6. threadFactory threadFactory
    • 表示生成线程池中工作线程的线程工厂,用于创建线程,一般默认的即可
  7. RejectedExecutionHandler handler
    • 拒绝策略,表示当队列满了,并且工作线程大于等于线程池的最大线程数(maximumPoolSize)时如何来拒绝请求执行的runnable的策略
    • 拒绝策略有以下四种:
    1. ThreadPoolExecutor.AbortPolicy()
      当线程池中任务数量超出 最多可容纳数 时,会直接抛出 RejectedExecutionException异常 阻止系统正常运行
    2. ThreadPoolExecutor.CallerRunsPolicy
      “调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
    3. ThreadPoolExecutor.DiscardOldestPolicy
      抛弃队列中等待最久的任务,然后把当前任务加人队列中尝试再次提交当前任务。
    4. ThreadPoolExecutor.DiscardPolicy
      该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种策略。

6. 线程池的底层工作原理

  1. 在创建了线程池后,开始等待请求。
  2. 当调用 execute() 方法添加一个请求任务时,线程池会做出如下判断:
    2.1. 如果正在运行的线程数量小于 核心线程数(corePoolSize),那么马上创建线程运行这个任务;
    2.2. 如果正在运行的线程数量大于或等于 核心线程数(corePoolSize),那么将这个任务放入 任务队列(workQueue)
    2.3. 如果这个时候 任务队列(workQueue) 满了且正在运行的线程数量还小于 最大线程数(maximumPoolSize),那么还是要创建非核心线程立刻运行 任务队列(workQueue) 中的任务;
    2.4. 如果workQueue队列满了且正在运行的线程数量大于或等于 最大线程数(maximumPoolSize),那么线程池会启动饱和拒绝策略来执行。
  3. 当一个线程完成任务时,它会从 任务队列(workQueue) 中取下一个任务来执行。
  4. 当一个线程无事可做超过一定的时间 空闲等待时间(keepAliveTime) 时,线程会判断:
    4.1. 如果当前运行的线程数大于 核心线程数(corePoolSize) ,那么这个线程就被停掉。
    4.2. 所以线程池的所它最终会收缩到 核心线程数(corePoolSize) 的大小**。

JUC-多线程(11.面试问题简析)学习笔记_第4张图片

7. 如果在线程池中使用无界阻塞队列会发生什么问题?等同于问,在远服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?

  • 无界阻塞队列:表示阻塞队列的长度是 Integer.MAX_VALUE
  • 可能存在堆积大量请求的情况,此时必然导致内存飙升,而且可能导致 内存溢出(OOM)

8. 线程池的队列满了之后,会发生什么?

  • 表示当队列满了,并且工作线程小于线程池的最大线程数(maximumPoolSize)时,则创建非核心线程立即执行队列中的任务。
  • 表示当队列满了,并且工作线程大于等于线程池的最大线程数(maximumPoolSize)时,则使用拒绝策略,拒绝策略有以下四种:
  1. ThreadPoolExecutor.AbortPolicy()
    当线程池中任务数量超出 最多可容纳数 时,会直接抛出 RejectedExecutionException异常 阻止系统正常运行
  2. ThreadPoolExecutor.CallerRunsPolicy
    “调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
  3. ThreadPoolExecutor.DiscardOldestPolicy
    抛弃队列中等待最久的任务,然后把当前任务加人队列中尝试再次提交当前任务。
  4. ThreadPoolExecutor.DiscardPolicy
    该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种策略。
  5. 自定义的拒绝策略

9. 如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办

  • 突然宕机,必然会导致线程池里积压的任务丢失
  • 如何解决
    1. 在提交任务到线程池之前,可以先将任务信息保存到数据库中,并且维护任务状态:未提交、已提交、已完成
    2. 这时突然宕机,重启服务器
    3. 从数据库中读取所有未提交、已提交的任务,重新提交到线程池中,继续执行

10. 对JAVA内存模型的理解

10-14 节是 java 内存模型连环炮:JAVA内存模型 -> 原子性、有序性、可见性 -> Volatile -> Volatile 和 可见性的关系 -> Volatile 和 有序性的关系 -> Volatile 和 原子性的关系 -> Volatile底层原理(内存屏障级别的原理)

  • 内存模型中的几种指令

    • lock(锁定):作用于主内存中的变量,它把一个变量标识为一条线程独占的状态。
    • unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    • read(读取):作用于主内存中的变量,它把一个变量从主内存传输到线程的工作内存中。
    • load(载入):作用于工作内存中的变量,它把read操作读取的值放入工作内存的变量副本中
    • use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时都会执行这个操作。
    • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时都会执行这个操作。
    • store(存储):作用于工作内存中的变量,它把一个变量的值传递到主内存中。
    • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  • 在多线程执行时,多个线程不能相互传递数据进行通信,它们之间的沟通只能通过共享变量来实现。

  • 内存模型规定了 JVM 拥有主内存,所有共享变量都存在主内存中

  • 每个线程都有属于自己的工作内存,这个一个 CUP 级别的内存,在工作内存中存储了所有使用到的变量的副本

  • 线程操作某个变量时,只能将变量读取到工作变量中进行操作,不能直接在主内存中进行

  • 当线程操作某个变量时,大致流程如下:

    1. read - 读取变量
    2. load - 将读取到的变量,加载到工作内存中
    3. use - 从工作内存中取出变量,对变量进行操作
    4. assign - 将操作结果再存入工作内存中
    5. store - 将工作内存中的变量,传递到主内存中
    6. write - 将传递来的值,写入主内存
  • 流程示意图
    JUC-多线程(11.面试问题简析)学习笔记_第5张图片

11. JAVA内存模型中的原子性、有序性、可见性

  • 可见性 就是如果有多个线程对一个数据进行操作时,如果一个线程成功修改了数据,那么其他线程能够立即更新工作内存中的该数据,即随时保持最新数据状态。这就叫有可见性,反之没有可见性。(如上个例子当线程1更新data=1,后立刻强制更新线程2获取data值为更新后的值)

  • 原子性 就是当有一个线程在对内存中的某个数据进行操作的时候,必须要等这个线程完全操作结束后,其他线程才能够操作,这就是原子性。反之就是没有原子性,多线程默认是没有原子性的,需要我们通过各种方式来实现原子性,如同步等等。(lock、unlock之间的内存操作具备原子性)

  • 有序性 就是代码的顺序应该和指令的顺序相同。在执行过程中不会发生指令重排,这就是有序性,反之就是没有有序性。

12. 从JAVA底层角度聊聊volatile关键字的原理

  • volatile 关键字是用来解决可见性和有序性,在有些罕见的条件之下,可以有限的保证原子性,他主要不是用来保证原子性的。

  • volatile 可以保证多个线程对变量进行操作时的可见性

    继续使用 10. 中的示意图,如果 data 变量用 volatile 关键字修饰
    当线程1 将 data=1 写入主内存之后,会强制将其他线程(线程2)的工作内存中的 data 值失效掉
    当线程2 需要对 data 执行 use 指令时,发现工作内存中 data 已经失效了,就会重新从主内存中读取加载

  • volatile 禁止进行指令重排序。可以保证有序性。

13. 指令重排以及happens-before原则

  • happens-before 原则,就是为了在一定程度上避免指令重排

    1. 程序次序规则:在一个线程内部,按照代码顺序,书写在前面的操作要先发生于写在后面的操作。
    2. 锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
    3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个 volatile 变量的读操作。简单说就是要保证先写再读
    4. 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
    5. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作
    6. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码监测到中断时间的发生
    7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测现场是否已经终止执行
    8. 对象终结规则:一个对象的初始完成先行发生于他的finalize()方法的开始。
  • 实际面试中被问到可以这么回答
    happens-before规则指定了在一些特殊情况下,不允许编译器、指令器对我们写的代码进行指令重排,必须保证代码的有序性。再简单说一下以上八种规则。

14.volatile底层是如何基于内存屏障保证可见性和有序性的?

  • volatile和原子性关系:volatile 不能够保证原子性,只有在一些极端情况下能保证原子性。
  • 想要保证原子性,还是需要使用 synchronized 和 lock 进行加锁
  1. lock指令:volatile保证可见性

    • 对volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完后会立即将这个值写回主内存,同时因为MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被修改了。
    • 其他线程如果发现自己工作内存中的变量被修改了,那么CPU就会将自己的本地缓存数据过期掉,然后从主内存中重新加载最新的数据。
  2. 内存屏障:禁止重排序

    对使用 volatile 修饰的变量,进行读写操作时,会在操作前后的代码前后加上屏障,防止指令重排

你可能感兴趣的:(多线程,面试,学习,jvm,多线程)