一、线程状态
线程是 cpu 任务调度的最小执行单位,每个线程拥有自己独立的程序计数器、虚拟机栈、本地方法栈。
线程状态包括:创建、就绪、运行、阻塞、死亡。
二、线程状态切换
三、阻塞唤醒过程
阻塞
以下三个方法的调用都会使当前线程阻塞,该线程将会被放置到对该 Object 的请求等待队列中,然后让出当前对 Object 所拥有的所有的同步请求。线程会一直暂停所有线程调度,直到以下其中一种情况发生:
- 其他线程调用了该 Object 的 notify 方法,而该线程刚好是那个被唤醒的线程;
- 其他线程调用了该 Object 的 notifyAll 方法。
唤醒
线程将会从等待队列中移除,重新成为可调度线程。它会与其他线程以常规的方式竞争对象同步请求。一旦它重新获得对象的同步请求,所有之前的请求状态都会恢复,也就是线程调用 wait 的地方的状态。线程将会在之前调用 wait 的地方继续运行下去。
为什么要出现在同步代码块中
由于 wait()属于 Object 方法,调用之后会强制释放当前对象锁,所以在 wait()调用时必须拿到当前对象的监视器 monitor 对象。因此,wait()方法在同步方法/代码块中调用。
四、wait 和 sleep 区别
- wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。
- wait 方法会主动释放 monitor 锁,在同步代码中执行 sleep 方法时,并不会释放 monitor 锁。
- wait 方法意味着永久等待,直到被中断或被唤醒才能恢复,不会主动恢复,sleep 方法中会定义一个时间,时间到期后会主动恢复。
- wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
五、创建线程方式
- 实现 Runnable 接口(优先使用);
- 实现 Callable 接口(有返回值可抛出异常);
- 继承 Thread 类(java 不支持多继承);
- 使用线程池(底层都是实现 run 方法)。
线程池
- 优点:通过复用已创建的线程,降低资源损耗、线程可以直接处理队列中的任务加快响应速度、同时便于统一监控和管理。
- 线程池构造函数参数介绍:
- 线程处理任务过程
- 当线程池小于 corePoolSize,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程。
- 当线程池达到 corePoolSize 时,新提交任务将被放入 workQueue 中,等待线程池中任务调度执行。
- 当 workQueue 已满,且 maximumPoolSize 大于 corePoolSize 时,新提交任务会创建新线程执行任务。
- 当提交任务数超过 maximumPoolSize 时,新提交任务由 RejectedExecutionHandler 处理。
- 当线程池中超过 corePoolSize 线程,空闲时间达到 keepAliveTime 时,关闭空闲线程。
- 线程拒绝策略
线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候需要拒绝策略机制合理的处理这个问题。JDK 内置的拒绝策略如下:
- AbortPolicy:直接抛出异常,阻止系统正常运行。可以根据业务逻辑选择重试或者放弃提交等策略。
- CallerRunsPolicy:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。不会造成任务丢失,同时减缓提交任务的速度,给执行任务缓冲时间。
- DiscardOldestPolicy:丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。
- DiscardPolicy:该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。
- Execuors 类实现线程池
- newSingleThreadExecutor():只有一个线程的线程池,任务是顺序执行,适用于一个一个任务执行的场景。
- newCachedThreadPool():线程池里有很多线程需要同时执行,60s 内复用,适用执行很多短期异步的小程序或者负载较轻的服务。
- newFixedThreadPool():拥有固定线程数的线程池,如果没有任务执行,那么线程会一直等待,适用执行长期的任务。
- newScheduledThreadPool():用来调度即将执行的任务的线程池。
- newWorkStealingPool():底层采用 forkjoin 的 Deque,采用独立的任务队列可以减少竞争同时加快任务处理。
- 手动创建线程池的优势
因为以上方式都存在弊端:
- FixedThreadPool 和 SingleThreadExecutor:允许请求的队列长度为 Integer.MAX_VALUE,会导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool:允许创建的线程数量为 Integer.MAX_VALUE,会导致 OOM。
- 手动创建的线程池底层使用的是 ArrayBlockingQueue 可以防止 OOM。
- 线程池大小设置
- CPU 密集型(n + 1):CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。
- IO 密集型(2 * n):由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2。也可以使用公式:CPU 核心数 *(1 + 平均等待时间/平均工作时间)。
六、线程安全
乐观锁,CAS 思想
- java 乐观锁机制
乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和 CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在 Java 中 java.util.concurrent.atomic 下的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁大多是基于数据版本 (Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。 - CAS 思想
CAS 就是 compare and swap(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用 CAS 线程是不会被阻塞的,所以又称为非阻塞同步。CAS 算法涉及到三个操作:
需要读写内存值 V;进行比较的值 A;准备写入的值 B。
当且仅当 V 的值等于 A 的值等于 V 的值的时候,才用 B 的值去更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作 - A 和 V 比较,V 和 B 替换),一般情况下是一个自旋操作,即不断重试。 - CAS 的缺点
- ABA 问题:高并发的情况下,很容易发生并发冲突,如果 CAS 一直失败,那么就会一直重试,浪费 CPU 资源。
- 原子性限制:功能限制 CAS 是能保证单个变量的操作是原子性的,在 Java 中要配合使用 volatile 关键字来保证线程的安全;当涉及到多个变量的时候 CAS 无能为力;除此之外 CAS 实现需要硬件层面的支持,在 Java 的普通用户中无法直接使用,只能借助 atomic 包下的原子类实现,灵活性受到了限制。
synchronized 底层实现
- 使用方法
- 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
- 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
- 总结:synchronized 锁住的资源只有两类:一个是对象,一个是类。
- 底层实现
对象头是我们需要关注的重点,它是 synchronized 实现锁的基础,因为 synchronized 申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由 Mark Word 组成,其中 Mark Word 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息。
锁也分不同状态,JDK6 之前只有两个状态:无锁、有锁(重量级锁),而在 JDK6 之后对 synchronized 进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头 Mark Word 中都有记录,在申请锁、锁升级等过程中 JVM 都需要读取对象的 Mark Word 数据。
同步代码块是利用 monitorenter 和 monitorexit 指令实现的,而同步方法则是利用 flags 实现的。
ReenTrantLock 底层实现
- 使用方法
基于 API 层面的互斥锁,需要 lock()和 unlock()方法配合 try/finally 语句块来完成。 - 底层实现
ReenTrantLock 的实现是一种自旋锁,通过循环调用 CAS 操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。 - 和 synchronized 区别
- 底层实现:synchronized 是 JVM 层面的锁,是 Java 关键字,通过 monitor 对象来完成(monitorenter 与 monitorexit),ReentrantLock 是从 jdk1.5 以来(java.util.concurrent.locks.Lock)提供的 API 层面的锁。
- 实现原理:synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向 OS 申请重量级锁;ReentrantLock 实现则是通过利用 CAS(CompareAndSwap)自旋机制保证线程操作的原子性和 volatile 保证数据可见性以实现锁的功能。
- 是否可手动释放:synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用;ReentrantLock 则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。
- 是否可中断:synchronized 是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成;ReentrantLock 则可以中断,可通过 trylock(long timeout,TimeUnit unit)设置超时方法或者将 lockInterruptibly()放到代码块中,调用 interrupt 方法进行中断。
- 是否公平锁:synchronized 为非公平锁,ReentrantLock 则即可以选公平锁也可以选非公平锁,通过构造方法 new ReentrantLock 时传入 boolean 值进行选择,为空默认 false 非公平锁,true 为公平锁,公平锁性能非常低。
公平锁和非公平锁区别
- 公平锁
公平锁自然是遵循 FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待。
优点:所有的线程都能得到资源,不会饿死在队列中。适合大任务。
缺点:吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu 唤醒阻塞线程的开销大。 - 非公平锁
多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
优点:可以减少 CPU 唤醒线程的开销,整体的吞吐效率会高点,CPU 也不必取唤醒所有线程,会减少唤起线程的数量。
缺点:这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁。
- 公平锁效率低原因
公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒。
线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。
使用层面锁优化
- 减少锁的时间:不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放。
- 减少锁的粒度:它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间。java 中很多数据结构都是采用这种方法提高并发操作的效率,比如:
- ConcurrentHashMap:java 中的 ConcurrentHashMap 在 jdk1.8 之前的版本,使用一个 Segment 数组:Segment< K,V >[] segments。Segment 继承自 ReenTrantLock,所以每个 Segment 是个可重入锁,每个 Segment 有一个 HashEntry< K,V >数组用来存放数据,put 操作时,先确定往哪个 Segment 放数据,只需要锁定这个 Segment,执行 put,其它的 Segment 不会被锁定。所以数组中有多少个 Segment 就允许同一时刻多少个线程存放数据,这样增加了并发能力。
- 锁粗化:大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度。假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的。
- 使用读写锁:ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可并发读,写操作使用写锁,只能单线程写。
- 使用 CAS:如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用 cas 效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用 volatiled + cas 操作会是非常高效的选择。
系统层面锁优化
- 自适应自旋锁:自旋锁可以避免等待竞争锁进入阻塞挂起状态被唤醒造成的内核态和用户态之间的切换的损耗,它们只需要等一等(自旋),但是如果锁被其他线程长时间占用,一直不释放 CPU,死等会带来更多的性能开销;自旋次数默认值是 10。对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。
- 锁消除:锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。Netty 中无锁化设计 pipeline 中 channelhandler 会进行锁消除的优化。
- 锁升级
- 偏向锁:如果线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些 monitor 操作,因为在大部分情况下是没有竞争的,所以使用偏向锁是可以提高性能的。
- 轻量级锁:在竞争不激烈的情况下,通过 CAS 避免线程上下文切换,可以显著的提高性能。
- 重量级锁:重量级锁的加锁、解锁过程造成的损耗是固定的,重量级锁适合于竞争激烈、高并发、同步块执行时间长的情况。
七、ThreadLocal 原理
- ThreadLocal 简介
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。类似操作系统中的 TLAB。 - 原理
首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map,是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。
最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。
我们使用的 get()、set()方法其实都是调用了这个 ThreadLocalMap 类对应的 get()、set()方法。 - ThreadLocal 内存泄漏的场景
实际上,ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。
弱引用的特点是:若对象持有弱引用,在下一次垃圾回收时必然会被清理掉。
所以,若 ThreadLocal 没有被外部强引用,在垃圾回收时会被清理,这样 ThreadLocalMap 中对应的 key 也会被清理。但 value 是强引用不会被清理,就会出现 key 为 null 的 value。若不采取任何措施,value 永远无法被 GC 回收,若线程长时间不销毁,可能产生内存泄漏。
ThreadLocalMap 在实现中已考虑这种情况,在调用 set()、get()、remove()方法时,会清理 key 为 null 的记录。只有在出现 key 为 null 的记录后,没有手动调用 remove()方法,并且之后也不再调用 get()、set()、remove()方法时,才会出现内存泄漏。因此,使用完 ThreadLocal 方法后,最好手动调用 remove()方法。
8、HashMap 线程安全
HashMap 可能出现死循环并导致 CPU 100%。主要原因是在扩容(即内部新建新的 HashMap 时),扩容逻辑会反转散列桶中的节点顺序。当多个线程同时扩容,由于 HashMap 非线程安全,若两个线程同时反转,可能形成链表循环(如 A 节点指向 B 节点,B 节点又指回 A 节点),这样下次获取 key 对应的 value 时,遍历链表会无法结束,从而导致 CPU 100%。
综上,HashMap 是线程不安全的,在多线程使用场景中推荐使用线程安全且性能较好的 ConcurrentHashMap。
9、String 不可变原因
- 可使用字符串常量池,多次创建同样的字符串会指向同一个内存地址。
- 可方便地用作 HashMap 的 key,通常建议把不可变对象作为 HashMap 的 key。
- hashCode 生成后就不会改变,使用时无需重新计算。
- 线程安全,因为具备不变性的对象一定是线程安全的。
内存模型
Java 内存模型(Java Memory Model,JMM)是一种符合内存模型规范的机制及规范,它屏蔽了各种硬件和操作系统的访问差异,保证了 Java 程序在各种平台下对内存的访问效果一致。
JMM 是为解决多线程通过共享内存通信时存在的问题,如本地内存数据不一致、编译器对代码指令重排序、处理器对代码乱序执行等,目的是保证并发编程场景中的原子性、可见性和有序性。
- 原子性:在 Java 中,通过两个高级字节码指令 Monitorenter 和 Monitorexit 保证原子性,这两个字节码在 Java 中对应的关键字是 Synchronized,所以可使用 Synchronized 保证方法和代码块内操作的原子性。
- 可见性:Java 中的 Volatile 关键字修饰的变量在被修改后能立即同步到主内存,且每次使用前从主内存刷新,可保证多线程操作时变量的可见性。此外,Synchronized 和 Final 关键字也能实现可见性,只是实现方式不同。
- 有序性:在 Java 中,可使用 Synchronized 和 Volatile 保证多线程之间操作的有序性。区别是:Volatile 禁止指令重排,Synchronized 保证同一时刻只允许一条线程操作。
1、volatile 底层实现
- 作用:
- 保证数据的“可见性”:被 volatile 修饰的变量能保证每个线程获取其最新值,避免数据脏读。
- 禁止指令重排:在多线程操作下,指令重排会导致计算结果不一致。
- 底层实现:观察加入 volatile 关键字和未加入时生成的汇编代码,加入 volatile 关键字时会多出一个 lock 前缀指令。lock 前缀指令相当于一个内存屏障(内存栅栏),有以下 3 个功能:
- 确保指令重排序时,不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面。
- 强制将对缓存的修改操作立即写入主存。
- 如果是写操作,会导致其他 CPU 中对应的缓存行无效。
- 单例模式中 volatile 的作用:防止代码读取到 instance 不为 null 时,instance 引用的对象尚未完成初始化。
class Singleton{private volatile static Singleton instance = null; //禁止指令重排private Singleton() { }public static Singleton getInstance() {if(instance==null) { //减少加锁的损耗synchronized (Singleton.class) {if(instance==null) //确认是否初始化完成instance = new Singleton();}}return instance;}
}
2、AQS 思想
AQS 全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量同步器,如基于 AQS 实现的 lock、CountDownLatch、CyclicBarrier、Semaphore。需解决的问题:
- 状态的原子性管理。
- 线程的阻塞与解除阻塞。
- 队列的管理。
AQS 核心思想是:若被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。若被请求的共享资源被占用,则需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH(虚拟的双向队列)队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
- lock:是一种可重入锁,除能完成 synchronized 所能完成的所有工作外,还提供可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。默认为非公平锁,但可初始化为公平锁,通过方法 lock()与 unlock()进行加锁与解锁操作。
- CountDownLatch:通过计数法(倒计时器),让一些线程堵塞直到另一个线程完成一系列操作后才被唤醒。该工具通常用来控制线程等待,可让某一个线程等待直到倒计时结束再开始执行,具体可使用 countDownLatch.await()等待结果,多用于多线程信息汇总。
- CompletableFuture:通过设置参数,可完成 CountDownLatch 同样的多平台响应问题,且可针对其中部分返回结果做更灵活的展示。
- CyclicBarrier:字面意思是可循环(Cyclic)使用的屏障(Barrier)。它让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才开门,所有被屏障拦截的线程才继续干活,线程进入屏障通过 CyclicBarrier 的 await()方法,可用于批量发送消息队列信息、异步限流。
- Semaphore:信号量主要用于两个目的,一是用于多个共享资源的互斥作用,二是用于并发线程数的控制,是 SpringHystrix 限流的思想。
3、happens - before
用于描述和可见性相关问题,若第一个操作 happens - before 第二个操作,则第一个操作对于第二个操作是可见的。
常见的 happens - before 情况:volatile、锁、线程生命周期。