跳至主要內容

Java 多线程知识点总结

Kaybee编程技术总结多线程约 3108 字大约 10 分钟

进程与线程、协程的区别?

  • 进程是操作系统进行资源分配的最小单位;线程是进程的一个执行单元,是 CPU 调度的基本单位

  • 进程之间的资源是互相独立的,一个进程内可以有多个线程运行,线程之间共享同一进程内的资源。

  • 进程间的切换开销大,线程由于轻量开销相对少

Java 线程的状态有哪几种?

  1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
  2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
    线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  3. 阻塞(BLOCKED):表示线程阻塞于锁。
  4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
  6. 终止(TERMINATED):表示该线程已经执行完毕。

Life_cycle_of_a_Thread_in_Java

并发问题的 3 个核心源头

  • 可见性 - 缓存
  • 原子性 - 线程切换
  • 有序性 - 编译优化

解决方案:JMM, volatile,final,synchronized

Java 内存模型(JMM)与 Happens-Before

JMM 是一种规范,

  • 所有变量都存储在主存中(Main Memory)
  • 每个线程都有一个本地内存(Local Memory),本地变量是主存的拷贝
  • 线程对变量的操作都必须在本地内存中进行,而不能直接读写主存
  • 不同线程之间无法直接访问对方本地内存的变量

Happens-Before 原则

什么是死锁,怎样排查死锁,避免死锁?

死锁:一组互相竞争资源的线程因互相等待,导致永久阻塞(blocked)的现象。

死锁发生的4个条件:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经占用资源 X,在等待资源 Y 的时候不释放资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占用的资源
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T 占用的资源

避免死锁,只要破坏上面其中一个条件就行:

  • 破坏占有且等待:一次申请所有资源,如果一次不能拿到所有资源就都不占用
  • 破坏不可抢占:申请不到其他资源时主动释放自己占用的资源
  • 破坏循环等待:资源要按照顺序获取

如何排查死锁:

  1. 使用 jps 查询正在运行的 Java 进程,得到进程 ID,然后使用 jstck $pid 查看Java 进程中的线程堆栈
  2. 或者直接使用 jConsole 可以检测死锁
  3. 用 VisualVM 可以直接查看线程,做线程 Dump

sleep(time),wait()方法啥区别?

  • sleep(time) 是 Thread 的静态方法,wait() 是 Object 的实例方法
  • sleep(time) 没有释放锁,wait() 释放了锁,都会释放 CPU资源
  • sleep(time) 用来短时间暂停当前线程,wait() 用于线程间通信
  • sleep(time) 可以在任何地方调用,但是 wait() 需要在临界区里面调用,不然会抛异常

线程池有哪些参数,工作原理,各参数如何合理配置?

  • corePoolSize 核心线程数

  • maximumPoolSize 最大线程数

  • keepAliveTime 当线程数量大于核心线程数时,多余线程的空闲存活时间

  • workQueue 工作队列

  • threadFactory 线程工厂

  • rejectedExecutionHandler 当线程数达到最大,并且工作队列已满时的处理逻辑

任务执行的规则如下:

情况1:在线程数没有达到核心线程数时,每个新任务都会创建一个新的线程来执行任务。

情况2:当线程数达到核心线程数时,每个新任务会被放入到等待队列中等待被执行。

情况3:当等待队列已经满了之后,如果线程数没有到达总的线程数上限,那么会创建一个非核心线程来执行任务。

情况4:当线程数已经到达总的线程数限制时,新的任务会被拒绝策略者处理,线程池无法执行该任务

线程数量配置规则:

  • CPU 密集型任务,设置为CPU核心数+1;
  • IO 密集型任务,设置为CPU核心数*2;

阻塞队列的配置:

(原则:指定长度,如果使用无界队列大量任务时可能会 OOM)

LinkedBlockingQueue 不指定长度为无界队列

ArrayBlockingQueue 需指定长度

SychronizeQueue 没有容量的队列

PriorityBlockingQueue 优先级队列

DelayedWorkQueue 延时队列

悲观锁,乐观锁,什么是 CAS

悲观锁:假定一定会遇到竞争,所以一开始就加锁执行

乐观锁:假定线程竞争很少会发生,执行的时候不加锁,冲突失败了就进行重试直到成功

CAS:比较并交换,是一条 CPU 原语,用于判断内存中某个值是否为预期值,如果是,则更新为新的值,这个过程是原子性的。如果否,则自旋重试。

CAS的问题:ABA 问题,自旋开销问题,只能保证单个变量的原子性

Java 里面 CAS应用:java.util..concurrent.atomic 包下的各种

无锁- ThreadLocal 原理,引用类型区别和场景,内存泄漏

ThreadLocal 是线程隔离的变量,每个线程都持有一份变量,所以不存在资源竞争。

ThreadLocal 原理:

在 Thread 类内部有一个 ThreadLocal.ThreadLocalMap threadlocals,ThreadLocalMap 里有个 Entry 的数组,这个 Entry 的 key 就是 ThreadLocal 对象,value 就是我们需要保存的值。

ThreadLocalMap 是通过开放寻址法来解决冲突,如果通过 key 的哈希值得到的下标无法直接命中,则会将下标 +1,即继续往后遍历数组查找 Entry ,直到找到或者返回 null。

ThreadLocal 源码分析:

ThreadLocal 类内部有一个静态内部类 ThreadLocalMap(可以供外部使用)

static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

ThreadLocalMap 里面的 Entry 继承自 WeakReference,当内存不够发生 GC 的时候就会回收 Entry 的 Key,但是 这个时候对应的 Value 却不会被回收。

ThreadLocal 内存泄漏

内存泄漏是指程序中已经无用的内存无法被释放,造成系统内存的浪费。

当前大多数的线程使用场景都是线程池,导致线程不会被回收,当 Entry 中的 key 即 ThreadLocal 对象被回收了之后,会发生 Entry 中 key 为 null 的情况,其实这个 Entry 就已经没用了,但是又无法被回收,因为有 Thread->ThreadLocalMap ->Entry 这条强引用在,这样没用的内存无法被回收就是内存泄露。

ThreadLocal 最佳实践

使用完之后 remove()

synchronized 底层原理,重量级锁,轻量锁,偏向锁

Java synchronized 关键字是基于管程(Monitor)的实现的, 在字节码里面会在执行的代码前后加入 monitorenter 和 monitorexit 指令。

管程原理

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,用来解决线程同步的问题。

MESA 管程模型

(MESA 管程模型 - 极客时间《Java并发编程实战》)

因为 synchronize 使用底层操作系统的 Mutex Lock 来实现,JDK1.6 之前 synchronize 性能并不好,每次获取和释放锁会带来用户态和内核态的切换。JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换。

在 JDK1.6 JVM 中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分组成。

jvm_Mark_Word_x64

(64 位 JVM的 Java 对象 中的 Mark Word 结构组成 - 极客时间《Java性能调优实战》 )

锁升级主要依赖于 Mark Word 中的锁标志位和释放偏向锁标志位,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

下图是锁升级的过程:

jvm_synchronize_process

(64 位 JVM的 Java 对象 中的 Mark Word 结构组成 - 极客时间《Java性能调优实战》 )

偏向锁

场景:偏向锁主要用来优化同一线程多次申请同一个锁的竞争。

偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的 Mark Word 中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。

一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占

轻量级锁

预估情况:多个线程在不同的时间段请求同一把锁,即在一个时间点上没有锁竞争

轻量级锁采用 CAS 操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。

重量级锁

如果一个线程试图进入一段加锁的代码,发现当前是重量级锁,当前线程会被挂起,进入阻塞。

java.util.concurrent 包下的工具类

  • Lock, Condition
  • Semaphore
  • ReadWriteLock
  • StampedLock
  • CountDownLatch,CyclicBarrier
  • Executor
  • CompletableFuture
  • ForkJoin

线程安全的级别

  1. Immutable 不可变的实例

  2. Thread-safe (unconditionally thread-safe) 无条件的线程安全/绝对的线程安全

    这个类的实例是可变的,但是有内部的同步措施,无需任何外部的同步,如 Random,ConcurrentHashMap

  3. Conditionally thread-safe 有条件的线程安全/相对的线程安全

    通常意思上说的线程安全,单独操作这个对象是线程安全的,但对一些方法的连续调用需要额外的同步来保证调用正确性,如 Vector

  4. Thread-compatible 线程兼容

    对象本身不是线程安全的,但是可以通过使用同步手段来保证在并发环境中的正确性

  5. Thread-hostile 线程对立

    无论是否采用同步措施都无法保证在并发下的正确性,如 Thread的 suspend() 和 resume() 都已废弃

线程安全的实现方法有哪些?

  1. 互斥同步 (阻塞同步)

    同步是指,并发访问共享数据时,保证同一时刻只有一个线程访问共享数据。

    互斥是实现同步的一种手段,临界区,互斥量,信号量都是主要的互斥实现方式。

    常见的互斥同步手段:

    • synchronized 关键字
    • ReentrantLock 支持等待超时,公平锁,绑定多个条件
  2. 非阻塞同步

    基于冲突检测的乐观并发策略:CAS (硬件指令的支持)

  3. 无同步方案

    • 可重入的代码: 不依赖任何共享数据,传入相同的参数必返回相同的结果
    • 线程本地存储:Thread Local Storage