5、线程与并发
5.1 ThreadLocal 的原理
ThreadLocal 的主要目的是用来实现多线程环境下的变量隔离
● 【解释】即每个线程自己用自己的资源,这样就不会出现共享,没有共享,就不会有多线程竞争的问题
原理
● 每个线程对象内部有一个 ThreadLocalMap,它用来存储这些需要线程隔离的资源
● 资源的种类有很多,比如说数据库连接对象、比如说用来判断身份的用户对象 ...
● 怎么区分它们呢,就是通过 ThreadLocal,它作为 ThreadLocalMap 的 key,而真正要线程隔离的资源作为 ThreadLocalMap 的 value
○ ThreadLocal.set 就是把 ThreadLocal 自己作为 key,隔离资源作为值,存入当前线程的 ThreadLocalMap
○ ThreadLocal.get 就是把 ThreadLocal 自己作为 key,到当前线程的 ThreadLocalMap 中去查找隔离资源
● ThreadLocal 一定要记得用完之后调用 remove() 清空资源,避免内存泄漏
5.2 解释悲观锁与乐观锁
悲观锁
● 像 synchronized,Lock 这些都属于悲观锁
● 如果发生了竞争,失败的线程会进入阻塞
● 【理解】悲观的名字由来:害怕其他线程来同时修改共享资源,因此用互斥锁让同一时刻只能有一个线程来占用共享资源
乐观锁
● 像 AtomicInteger,AtomicReference 等原子类,这些都属于乐观锁
● 如果发生了竞争,失败的线程不会阻塞,仍然会重试
● 【理解】乐观的名字由来:不怕其他线程来同时修改共享资源,事实上它根本不加锁,所有线程都可以去修改共享资源,只不过并发时只有一个线程能成功,其它线程发现自己失败了,就去重试,直至成功
适用场景
● 如果竞争少,能很快占有共享资源,适合使用乐观锁
● 如果竞争多,线程对共享资源的独占时间长,适合使用悲观锁
P.S.
● 这里讨论 Java 中的悲观锁和乐观锁,其它领域如数据库也有这俩概念,当然思想是类似的
5.3 synchronized 原理
以重量级锁为例,比如 T0、T1 两个线程同时执行加锁代码,已经出现了竞争(代码如下)
synchronized(obj) { // 加锁
...
} // 解锁
- 当执行到行1 的代码时,会根据 obj 的对象头找到或创建此对象对应的 Monitor 对象(C++对象)
- 检查 Monitor 对象的 owner 属性,用 Cas 操作去设置 owner 为当前线程,Cas 是原子操作,只能有一个线程能成功
a. 假设 T0 Cas 成功,那么 T0 就加锁成功,可以继续执行 synchronized 代码块内的部分
b. T1 这边 Cas 失败,会自旋若干次,重新尝试加锁,如果
ⅰ. 重试过程中 T0 释放了锁,则 T1 不必阻塞,加锁成功
ⅱ. 重试时 T0 仍持有锁,则 T1 会进入 Monitor 的等待队列阻塞,将来 T0 解锁后会唤醒它恢复运行(去重新抢锁)
5.4【追问】 synchronized 锁升级
synchronized 锁有三个级别:偏向锁、轻量级锁、重量级锁,性能从左到右逐渐降低
● 如果就一个线程对同一对象加锁,此时就用偏向锁
● 又来一个线程,与前一个线程交替为对象加锁,但只是交替,没有竞争,此时要升级为轻量级锁
● 如果多个线程加锁时发生了竞争,必须升级为重量级锁
【说明】
● 自 java 6 开始对 synchronized 提供了锁升级功能,之前只有重量级锁
● 但从 java 15 开始,偏向锁被标记为已废弃,将来会移除(因为实际带来的性能提升不明显,某些情况下反而影响性能)
5.5 对比 synchronized 和 volatile
并发编程需要从三个方面考虑线程安全,分别是:原子性、可见性、有序性
● volatile 修饰共享变量,可以保证它的可见性和有序性,但不能保证原子性(JMM模型)
● synchronized 代码块,不仅能保证共享变量的可见性、有序性,同时也能保证原子性
P.S.
● 实际上用 volatile 去保证可见性和有序性,并不像上面那一句话描述的那么简单,可以参考黑马课程
5.6 对比 synchronized 和 Lock
● synchronized 是关键字,Lock 是 Java 接口
● 前者底层是 C++ 代码实现锁,后者是 Java 自己的代码来实现锁
● Lock 功能更多,比如可以选择是公平锁还是非公平锁、可以设置加锁超时时间、可打断等
● Lock 的提供多种扩展实现(例如读写锁),可以根据场景选择更合适的实现
● Lock 释放锁需要调用 unlock 方法,而 synchronzied 在代码块结束无需显式调用就可以释放锁
5.7 线程池的核心参数
记忆七个参数
- 核心线程数
a. 核心线程会常驻线程池 - 最大线程数
a. 如果同时执行的任务数超过了核心线程数,且队列已满,会创建新的线程来救急
b. 总线程数(新线程+原有的核心线程)不超这个最大线程数 - 存活时间
a. 超过核心线程数的线程一旦闲下来,会存活一段时间,然后被销毁 - 存活时间单位
- 工作队列
a. 如果同时执行的任务数超过了核心线程数,会把暂时无法处理的任务放入此队列 - 线程工厂
a. 可以控制池中线程的命名规则,是否是守护线程等(不太重要的参数) - 拒绝策略,队列放满任务,且所有线程都被占用,再来新任务,就会有问题,此时有四种拒绝策略:
a. AbortPolicy 报错策略,直接抛异常
b. CallerRunsPolicy 推脱策略,线程池不执行任务,推脱给任务提交线程
c. DiscardOldestPolicy 抛弃最老任务策略,把队列中最早的任务抛弃,新任务加入队列等待
d. DiscardPolicy 抛弃策略,直接把新任务抛弃不执行
6、JVM 虚拟机
6.1 JVM 堆内存结构
堆内存的布局与垃圾回收器有关。
传统的垃圾回收器会把堆内存划分为:老年代和年轻代,年轻代又分为
● 伊甸园 Eden
● 幸存区 S0,S1
如果是 G1 垃圾回收器,会把内存划分为一个个的 Region,每个 Region 都可以充当
● 伊甸园
● 幸存区
● 老年代
● 巨型对象区
6.2 垃圾回收算法
记忆三种:
- 标记-清除算法。优点是回收速度快,但会产生内存碎片
- 标记-整理算法。相对清除算法,不会有内存碎片,当然速度会慢一些
- 标记-复制算法。将内存划分为大小相等的两个区域 S0 和 S1
a. S0 的职责用来存储对象,S1 始终保持空闲
b. 垃圾回收时,只需要扫描 S0 的存活对象,把它们复制到 S1 区域,然后把 S0 整个清空,最后二者互换职责即可
c. 不会有内存碎片,特别适合存活对象很少时(因为此时复制工作少)
6.3【追问】伊甸园、幸存区、老年代细节
● 对象最初都诞生在伊甸园,这些对象通常寿命都很短,在伊甸园空间不足,会触发年轻代回收,还活着的对象进入幸存区 S0,年轻代回收适合采用标记-复制算法
● 接下来再触发年轻代回收时,会将伊甸园和 S0 仍活着的对象复制到 S1,清空 S0,交换 S0 和 S1 职责
● 经过多次回收仍不死的对象,会晋升至老年代,老年代适合放那些长时间存活的对象
● 老年代回收如果满了,会触发老年代垃圾回收,会采用标记-整理或标记-清除算法。老年代回收时的暂停时间通常比年轻代回收更长
还会常问
晋升条件
● 注意不同垃圾回收器,晋升条件不一样
● 在 parallel 里,经历 15 次(默认值)新生代回收不死的对象,会晋升
○ 可以通过 -XX:MaxTenuringThreshold 来调整
○ 例外:如果幸存区中的某个年龄对象空间占比已经超过 50%,那么大于等于这个年龄的对象会提前晋升
大对象的处理
● 首先大对象不适合存储在年轻代,因为年轻代是复制算法,对象移动成本高
● 注意不同垃圾回收器,大对象处理方式也不一样
● 在 serial 和 cms 里,如果对象大小超过阈值,会直接把大对象晋升到老年代
○ 这个阈值通过 -XX:PretenureSizeThreshold 来设置
● 在 g1 里,如果对象被认定为巨型对象(对象大小超过了 region 的一半),会存储在巨型对象区
○ Region 大小是堆内存总大小 / 2048(必须取整为2的幂),或者通过 -XX:G1HeapRegionSize 来设置
P.S.
著名教材《深入理解Java虚拟机》一书关于这些论述,很多观点陈旧过时,需要带批判眼光来学习。例如在它的《内存分配与回收策略》这一章节,提到了这些:
● 对象优先在Eden分配(OK)
● 大对象直接进入老年代(没有提到 g1 情况)
● 长期存活的对象将进入老年代(即我上面讲的晋升条件,但没强调要区分垃圾回收器)
● 动态对象年龄判定(即提前晋升)
● 空间分配担保(已过时)文中提到的 -XX:+HandlePromotionFailure 参数在 jdk8 之后已经没了
7、Lambda表达式
什么是 Lambda 表达式
● 文献中把 Lambda 表达式一般称作匿名函数,语法为 (参数部分) -> 表达式部分
● 它本质上是一个函数对象
● 它可以用在那些需要将行为参数化的场景,例如 Stream API,MyBatisPlus 的 QueryWrapper 等地方
Lambda 与匿名内部类有何异同
● 它们都可以用于需要行为参数化的场景
● Lambda 表达式必须配合函数式接口使用,而匿名内部类不必拘泥于函数式接口,其它接口和抽象类也可以
● Lambda 表达式比匿名内部类语法上更加简洁
● 匿名内部类是在编译阶段由程序员编写提供,而 Lambda 表达式是在运行阶段动态生成它所需的类
● 【进阶】Lambda 中 this 含义与匿名内部类中的 this 不同