Java多线程

多线程常见问题

Posted by slHu on June 28, 2021

多线程相关

1)Java内存模型

java内存模型定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存。

2)as-if-serial

编译器等会对原始的程序进行指令重排序和优化。但不管怎么重排序,其结果和用户原始程序输出预定结果一致。

3)简述happens-before八大原则

  • 程序次序规则:一个线程内写在前面的操作先行发生于后面的。
  • 锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作。
  • 线程启动规则:线程的 start 方法先行发生于线程的每个动作。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  • 线程终止规则:线程中所有操作先行发生于对线程的终止检测。
  • 对象终结规则:对象的初始化先行发生于 finalize 方法。
  • 传递性规则:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作C

as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。

4)简述原子性操作

一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行,这就是原子性操作。

5)volatile关键字的作用

  • 保证变量对所有线程的可见性 当一条线程修改了变量值,新值对于其他线程来说是立即可以得知的。
  • 禁止指令重排。 使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,编译器不会将后面的指令重排到内存屏障之前。

6)Java线程的状态

  • NEW:新建状态,线程被创建且未启动,此时还未调用 start 方法。
  • RUNNABLE: 运行状态。其表示线程正在JVM中执行,但是这个执行,不一定真的在跑,也可能在排队等CPU。
  • BLOCKED:阻塞状态。线程等待获取锁,锁还没获得。
  • WAITING: 等待状态。线程内run方法运行完语句Object.wait()/Thread.join()进入该状态。
  • TIMED_WAITING:限期等待。在一定时间之后跳出状态。调用Thread.sleep(long) Object.wait(long) Thread.join(long)进入状态。其中这些参数代表等待的时间。
  • TERMINATED:结束状态。线程调用完run方法进入该状态。

7)线程通信的方式

  • volatile关键词修饰变量,保证所有线程对变量访问的可见性
  • synchronized关键词。确保多个线程在同一时刻只能有一个处于方法或同步块中。
  • wait、notify方法
  • IO通信

等待通知机制实现原理

  • 每个锁对象都又两个队列,一个是就绪队列,一个是阻塞队列
  • 就绪队列中存储了将要获得锁的线程,阻塞队列中存储了被阻塞的线程
  • 一个线程被唤醒后,会进入就绪队列,等待 CPU 调度
  • 一个线程被 wait 后就会进入阻塞队列,等待其他线程调用 notify,它才会被选中进入就绪队列,等待被 CPU 调度

8)简述线程池

  • 没有线程池的情况下,多次创建,销毁线程开销比较大。如果在开辟的线程执行完当前任务后执行接下来任务,复用已创建的线程,降低开销、控制最大并发数。
  • 线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后还会循环获取工作队列中的任务来执行。

  • 将任务派发给线程池时,会出现以下几种情况
    1. 核心线程池未满,创建一个新的线程执行任务。
    2. 如果核心线程池已满,工作队列未满,将线程存储在工作队列。
    3. 如果工作队列已满,线程数小于最大线程数就创建一个新线程处理任务。
    4. 如果超过大小线程数,按照拒绝策略来处理任务。

9)线程池的参数

  1. corePoolSize:常驻核心线程数。超过该值后如果线程空闲会被销毁。
  2. maximumPoolSize:线程池能够容纳同时执行的线程最大数。
  3. keepAliveTime:线程空闲时间,线程空闲时间达到该值后会被销毁,直到只剩下 corePoolSize 个 线程为止,避免浪费内存资源。
  4. workQueue:工作队列。
  5. threadFactory:线程工厂,用来生产一组相同任务的线程。
  6. handler:拒绝策略。有以下几种拒绝策略:
    • AbortPolicy:丢弃任务并抛出异常
    • CallerRunsPolicy: 重新尝试提交该任务
    • DiscardOldestPolicy 抛弃队列里等待最久的任务并把当前任务加入队列
    • DiscardPolicy 表示直接抛弃当前任务但不抛出异常。

10)ThreadLocal

ThreadLocal 是线程共享变量。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是ThreadLocal 对象,值是 Entry 对象,ThreadLocalMap是每个线程私有的。

1. set 给ThreadLocalMap设置值。
2. get 获取ThreadLocalMap。
3. remove 删除ThreadLocalMap类型的对象。

存在的问题

  • 对于线程池,由于线程池会重用 Thread 对象,因此与Thread 绑定的 ThreadLocal 也会被重用,造成一系列问题。
  • 内存泄漏。由于 ThreadLocal 是弱引用,但 Entry 的value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放,产生内存泄漏。

11)Java中的乐观锁与CAS算法

  • 悲观锁: 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,虽然安全,但并发性极差,如 Java 的 synchronized。
  • 乐观锁: 每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在提交更新的时候会判断一下在此期间别人有没有去更新这个数据,如 CAS。

  • 对于乐观锁,开发者认为数据发送时发生并发冲突的概率不大,所以读操作前不上锁。
  • 到了写操作时才会进行判断,数据在此期间是否被其他线程修改。如果发生修改,那就返回写入失败;如果没有被修改,那就执行修改操作,返回修改成功。

  • 乐观锁一般都采用 Compare And Swap(CAS)算法进行实现。顾名思义,该算法涉及到了两个操作, 比较(Compare)和交换(Swap)。

CAS 算法的思路如下:

- 1. 该算法认为不同线程对变量的操作时产生竞争的情况比较少。
- 2. 该算法的核心是对当前读取变量值 E 和内存中的变量旧值 V 进行比较。
- 3. 如果相等,就代表其他线程没有对该变量进行修改,就将变量值更新为新值 N。
- 4. 如果不等,就认为在读取值 E 到比较阶段,有其他线程对变量进行过修改,不进行任何操作。

12)ABA问题及解决方法简述

CAS 算法是基于值来做比较的,如果当前有两个线程,一个线程将变量值从 A 改为 B ,再由 B 改回为A ,当前线程开始执行 CAS 算法时,就很容易认为值没有变化,误认为读取数据到执行 CAS 算法的期间,没有线程修改过数据。

  • juc 包提供了一个 AtomicStampedReference,即在原始的版本下加入版本号戳,解决 ABA 问题。

13)简述ConcurrentHashMap

  • JDK7采用锁分段技术。首先将数据分成 Segment 数据段,然后给每一个数据段配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。
  • get 除读到空值不需要加锁。该方法先经过一次再散列,再用这个散列值通过散列运算定位到Segment,最后通过散列算法定位到元素。
  • put 须加锁,首先定位到 Segment,然后进行插入操作,第一步判断是否需要对 Segment 里的HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放入数组。

JDK8的改进

1. 取消分段锁机制,采用CAS算法进行值的设置,如果CAS失败再使用 synchronized 加锁添加元素
2. 引入红黑树结构,当某个槽内的元素个数超过8且 Node数组 容量大于 64 时,链表转为红黑树。
3. 使用了更加优化的方式统计集合内的元素数量。

14)Synchronized底层实现原理

  • Java 对象底层都关联一个的 monitor,使用 synchronized 时 JVM 会根据使用环境找到对象的monitor,根据 monitor 的状态进行加解锁的判断。如果成功加锁就成为该 monitor 的唯一持有者,monitor 在被释放前不能再被其他线程获取。
  • synchronized在JVM编译后会产生monitorenter 和 monitorexit 这两个字节码指令,获取和释放monitor。这两个字节码指令都需要一个引用类型的参数指明要锁定和解锁的对象,对于同步普通方法,锁是当前实例对象;对于静态同步方法,锁是当前类的 Class 对象;对于同步方法块,锁是synchronized 括号里的对象。
  • 执行 monitorenter 指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1,执行monitorexit 指令时会将锁计数器减 1。一旦计数器为 0 锁随即就被释放。

15)Java锁的升级过程

Java中对象锁有4种状态:无锁、偏向锁、轻量级锁、自旋锁、重量级锁

偏向锁:偏向锁是JDK6中引入的一项锁优化,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

  • 场景1: 程序不会有锁的竞争。

那么这种情况我们不需要加锁,所以这种情况下对象锁状态为无锁。

  • 场景2: 经常只有某一个线程来加锁。

加锁过程:也许获取锁的经常为同一个线程,这种情况下为了避免加锁造成的性能开销,所以并不会加实际意义上的锁,偏向锁的执行流程如下:

- 1、线程首先检查该对象头的线程ID是否为当前线程;
- 2、A:如果对象头的线程ID和当前线程ID一直,则直接执行代码;B:如果不是当前线程ID则使用CAS方式替换对象头中的线程ID,如果使用CAS替换不成功则说明有线程正在执行,存在锁的竞争,这时需要撤销偏向锁,升级为轻量级锁。
- 3、如果CAS替换成功,则把对象头的线程ID改为自己的线程ID,然后执行代码。
- 4、执行代码完成之后释放锁,把对象头的线程ID修改为空。
  • 场景3: 有线程来参与锁的竞争,但是获取锁的冲突时间很短。

当开始有锁的冲突了,那么偏向锁就会升级到轻量级锁;线程获取锁出现冲突时,线程必须做出决定是继续在这里等,还是回家等别人打电话通知,而轻量级锁的路基就是采用继续在这里等的方式,当发现有锁冲突,线程首先会使用自旋的方式循环在这里获取锁,因为使用自旋的方式非常消耗CPU,当一定时间内通过自旋的方式无法获取到锁的话,那么锁就开始升级为重量级锁了。

  • 场景4: 有大量的线程参与锁的竞争,冲突性很高。

我们知道当获取锁冲突多,时间越长的时候,我们的线程肯定无法继续在这里死等了,所以只好先休息,然后等前面获取锁的线程释放了锁之后再开启下一轮的锁竞争,而这种形式就是我们的重量级锁。