JUC
JMM了解吗
Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的。
这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。
其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。
最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。
Java中有哪些常用的锁
- 内置锁(synchronized):Java中的
synchronized
关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized
代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。 - ReentrantLock:
java.util.concurrent.locks.ReentrantLock
是一个显式的锁类,提供了比synchronized
更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock
使用lock()
和unlock()
方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。 - 读写锁(ReadWriteLock):
java.util.concurrent.locks.ReadWriteLock
接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。 - 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。
synchronized
和ReentrantLock
都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。 - 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。
线程池的核心参数
-
corePoolSize 核心线程数目
-
maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
-
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
-
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
-
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
-
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
-
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
一个任务提交到线程池的流程
1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
拒绝策略:
1.AbortPolicy:直接抛出异常,默认策略;
2.CallerRunsPolicy:用调用者所在的线程来执行任务;
3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4.DiscardPolicy:直接丢弃任务;
线程池创建方法
-
通过
ThreadPoolExecutor
构造方法创建- 这是最基础也是最灵活的创建线程池的方式。可以根据前面介绍的各种参数来自定义线程池
-
通过
Executors
工厂方法创建
-
Java 提供了
Executors
工厂类,它有一些方便的静态方法来创建常见的线程池。 -
常见的工厂方法如下:
-
newFixedThreadPool(int nThreads)
:创建一个固定大小的线程池,其核心线程数和最大线程数都等于参数nThreads
,任务队列是一个无界的LinkedBlockingQueue
。例如,Executors.newFixedThreadPool(5)
表示创建一个大小为 5 的固定线程池。 -
newCachedThreadPool()
:创建一个可缓存的线程池。它的核心线程数为 0,最大线程数为Integer.MAX_VALUE
,当线程空闲时间超过 60 秒(默认值)时,线程会被回收。任务队列是一个SynchronousQueue
。这种线程池适合执行大量的短时任务,因为线程可以快速创建和回收。例如,在一个高并发的短时任务处理场景中,如处理 HTTP 请求的小型任务,可以使用这个线程池。 -
newSingleThreadExecutor()
:创建一个单线程的线程池。它的核心线程数和最大线程数都为 1,任务队列是一个无界的LinkedBlockingQueue
。这种线程池适合用于需要保证任务按顺序执行的场景,比如日志记录任务,因为只有一个线程,可以保证任务的执行顺序和提交顺序一致。 -
newScheduledThreadPool(int corePoolSize)
:创建一个支持定时和周期性任务的线程池。它和newFixedThreadPool
类似,但是它是为定时任务设计的。可以用于任务调度,如定时发送邮件、定时备份数据等场景。
-
有线程池参数设置的经验吗?
核心线程数(corePoolSize)设置的经验:
- CPU密集型:corePoolSize = CPU核数 + 1(避免过多线程竞争CPU)
- IO密集型:corePoolSize = CPU核数 x 2(或更高,具体看IO等待时间)
线程池参数设定过大的影响
最大线程数和核心线程数设定过大的影响
- 下文切换开销
当活跃线程数远超 CPU 核心数时,每个线程执行时间片被大幅缩减,系统在调度线程之间频繁切换,消耗大量 CPU 周期而非执行业务逻辑,导致整体吞吐量和响应时延双双恶化
- 资源竞争与锁争用
大量并发线程在共享数据结构或锁上激烈争用,频繁出现线程阻塞和唤醒,进一步加剧上下文切换,并可能导致缓存行抖动(cache thrashing),削弱多核优势
- 内存占用与 OOM
每创建一个线程,JVM 通常会为其分配约 1MB 的本地栈空间,如果线程数设为数万甚至更高,就会迅速耗尽系统内存或本地内存配额,引发 OutOfMemoryError
- 垃圾回收压力
线程对象的不断创建与销毁,以及由此产生的附属对象(如任务包装器),都会增加 JVM 对堆内存的扫描与整理成本,可能触发频繁的全量 GC 或 GC 长时间停顿
- 线程泄露
默认情况下,corePoolSize
内的线程不会超时回收;若该值设得过大且未启用 allowCoreThreadTimeOut(true)
,将导致大批空闲线程长期驻留并占用资源,增加系统不稳定风险
- 队列与拒绝策略失效
即使配合合理的阻塞队列和拒绝策略,当最大线程数过大时,队列很可能先于达到 maximumPoolSize
而被填满,使得拒绝策略或降级方案迟迟得不到触发,破坏系统平滑降级能力
队列过长的主要影响
- 内存溢出 (OOM)
使用无界队列会让待执行任务不断累积,最终耗尽内存并抛出
OutOfMemoryError
- 垃圾回收压力增大 大量堆积的任务对象会占用堆内存,触发频繁的 GC,严重时会出现 “GC overhead limit exceeded”
- 系统吞吐量下降与高延迟 任务堆积在线程池队列中时,新任务无法及时执行,导致整体吞吐量下降、响应时延显著增加
- 最大线程数失效与拒绝策略失灵
当队列容量巨大或使用无界队列时,线程数只会达到
corePoolSize
而不会扩展至maximumPoolSize
,导致拒绝策略永远不被触发 - 任务饥饿死锁 若队列内的任务相互依赖(如 A 任务等待 B 任务结果),大量任务排队会造成线程饥饿死锁,线程池无法分配足够线程解决依赖
synchronized关键字的底层原理
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
我们主要来看看 Mark Word 的格式:
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程 ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC 标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时,Mark Word
存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word
存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁时,Mark Word
为指向堆中的 monitor(监视器)对象的指针。
在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器。
监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object 类中的wait()
, notify()
, notifyAll()
等方法实现的
一旦锁发生了竞争,都会升级为重量级锁
synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。
synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
monitor内部维护了三个变量
-
WaitSet:保存处于Waiting状态的线程
-
EntryList:保存处于Blocked状态的线程
-
Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
synchronized和reentrantlock区别?
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:
- 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
- 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
- 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
ThreadLocal 实现原理
ThreadLocal
在 Java 中不是依赖操作系统 TLS,而是在每个 Thread
对象中维护一个 ThreadLocalMap
,键为 ThreadLocal
实例(弱引用),值为对应线程私有变量。访问 get()/set()
时取当前线程的 map 操作。
volatile了解吗
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
-
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
-
禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
可见性
只要给data这个变量在定义时加一个volatile,就可以解决可见性的问题。
第一、data变量定义时加了volatile修饰,那么线程A只要修改data变量的值,就会在修改完自己本地工作内存的data变量之后,强制将这个data变量最新的值刷回主内存,必须让主内存里的data变量值立马变成最新的值。
第二、如果此时别的线程的工作内存中有这个data变量的本地缓存,也就是一个变量的副本,那么会强制让其他线程的工作内存中的data变量缓存直接失效过期,不允许再次读取和使用。
第三、如果线程B在代码运行过程中再次需要读取data变量值时,此时尝试从本地工作内存中读取,就会发现data已经过期。此时,就必须重新从主内存中加载data变量最新的值。