记录一下秋招面试遇到关于 Java 的问题,会持续更新。
1. 基础
1.1 面向对象和面向过程区别
- 面向过程:性能比面向对象高。因为类调用时需要实例化,开销比较大,比较消耗资源。
- 面向对象:面向对象易维护、易复用、易扩展。因为面向对象有封装,继承和多态的特性,所以可以设计出低耦合的系统,使系统更加灵活,更加易于维护。
1.2 堆和栈的区别
- 申请方式不同。栈由系统自动分配,堆是人为申请开辟。
- 申请大小不同。栈获得空间较小,堆获得的空间较大。
- 申请效率不同。栈是系统自动分配,速度比较快,而堆一般速度比较慢。
- 存储内容不同。栈存储的是局部变量,堆存储的是数组和对象。
- 底层不同。栈是连续的空间,而堆是不连续的空间。
1.3 Java 为什么不能重载运算符
Java 这一门语言的设计目的之一是清晰性,添加运算符重载会使设计更加复杂,而且一种运算符赋予多种定义,会污染代码,增加维护成本。
1.4 谈谈组合和继承
- 继承
优点:简单,易实现。
缺点:
- 破坏类的封装性,父类的实现细节暴露给子类,父类对子类来说是透明的。
- 子类和父类的耦合度高,父类的实现改变影响到子类实现,不利于扩展和维护。
- 限制复用灵活性,继承复用是静态的,编译时已经定义,运行时无法修改。
- 组合
- 维持类的封装性。
- 对象间耦合度低,可以在类的成员位置声明抽象。
- 复用的灵活度高。
1.5 讲讲面向对象的理解/特性
面向对象有三大特性:封装,继承,多态。
封装在 Java 里面就是可以把一些属性封装到类里面,通过修饰符保护这些成员变量。
继承在 Java 里面就是通过子类继承父类,去达到代码复用的效果。
多态在 Java 里面就是接口的多种实现和父类的子实现类,在编译时引用变量指向哪一个实例对象是不确定的,只有运行时才能确定,这样就可以把变量绑定到不同的类实例上,让程序拥有了多个运行状态。
1.6 讲讲接口和抽象类
从设计思想上分析。
接口的设计目的是对类的行为进行约束,也就是提供一种机制,可以强制要求不同的类具有相同的行为,只约束了行为的有无,但不对如何实现行为进行限制。
抽象类的设计目的是代码复用。当不同的类具有相同的行为且其中一部分行为的实现方式一致时,可以让这些类都派生于一个抽象类。 抽象类中实现相同的行为,避免了所有子类来实现这些行为,也就达到了代码复用的目的。值得一提的是,因为这里的话抽象类只是实现了那些相同的行为,但是那些不同的行为仍需要子类去实现,所以这里的抽象类是不允许实例化出来的。
1.7 == 和 equals 的区别
==: 如果是基本数据类型,就是判断数值是否相等;如果是对象,就是判断两个对象的地址是否相等。
equals:
- 类覆盖了 equals(),比较的是内容。
- 类没有覆盖 equals(),比较的是地址,等同于 “==”。
1.8 equals 和 hashCode 的关系
hashCode:hashCode() 定义在 JDK 的 Object 类中,是一个本地方法(native),通过把对象的内存转换成一个 int 整数后返回。
hashCode 主要是用来定位对象在哈希表,或者说集合中的一个位置,通过这个 int 值可以快速判断是否有相同的对象,大大减少了 equals 的次数,提供了执行速度。
其次,由于 hashCode 返回值的本质上是一个哈希值,也就意味着存在哈希碰撞的可能性,所以仍需要用 equals 来判断对象是否相同。
所以如果重写 equals 的时候,就必须重写 hashCode。
1.9 Lambda 表达式实现原理
Lambda 表达式的实现原理就是修改字节码,以多态实现。
编译时,编译器会根据 lambda 表达式的内容生成一个私有的静态函数 lambda$0,然后通过静态方法 metafactory 为 lambda 表达式生成另外一个实现了原接口的 final 内部类 $Lambda$1,并在 $Lambda$1 的方法里调用 lambda$0 。
@FunctionalInterface
interface Print<T> {
public void print(T x);
}
public class Lambda {
public static void PrintString(String s, Print<String> print) {
print.print(s);
}
private static void lambda$0(String x) {
System.out.println(x);
}
final class $Lambda$1 implements Print{
@Override
public void print(Object x) {
lambda$0((String)x);
}
}
public static void main(String[] args) {
PrintString("test", new Lambda().new $Lambda$1());
}
}
javap -p Lambda.class 查看字节码
2. 容器
2.1 Set
2.1.1 HashSet 底层
底层是 HashMap,put 的话 value 是一个 static final 修饰的 Object(new Object() 生成的)。
2.1.2 HashSet contains 复杂度
HashSet 底层是 HashMap,contains 的复杂度跟 HashMap 的 containsKey 一样。
如果没有哈希冲突就是 O(1),如果有哈希冲突要判断结点是链表结点还是红黑树结点,链表结点则复杂度是 O(n),红黑树结点复杂度是 O(logN)。
2.2 Map
2.2.1 ConcurrentHashMap 默认并发度
ConcurrentHashMap 在 1.7 的实现是分段锁,主要是凭借 Segment 实现。
并发度也就是 Segment 数组的大小,默认是 16。
2.2.2 ConcurrentHashMap 实现原理
1.7 版本,底层数据结构是由 Segmant 数组 + HashEntry 数组实现,核心思想是分段锁,锁的单位是段数组。
1.8 版本,底层数据结构是由 volatile 修饰的 Node 数组 + 链表 + 红黑树实现,核心思想是用 Synchronized 和 CAS 实现,先 CAS 自旋,自旋失败再去用 Synchronized 锁住链表的头节点或者红黑树的根节点。
2.2.3 ConcurrentHashMap 1.8 为什么用红黑树而不用搜索树/平衡树(AVL)
用红黑树的原因是为了哈希冲突时把链表检索的复杂度 O(n) 优化到红黑树检索的复杂度 O(logN)。
AVL 追求极致平衡,能够提供更快的查找速度,一般用于读取密集型的任务。
红黑树更适合插入修改密集型任务。
两者都是 O(logN) 查找,但是 AVL 平均需要 O(logN) 次旋转,而红黑树最多只需要两到三次旋转就可以达到平衡。(旋转是 O(1) 的操作,修改指针)
2.2.4 HashMap new HashMap(20) 容量最终为多少
声明容量不是 2 的幂数,他会在构造函数里调用 tableSizeFor,根据传入的容量不断左移并且异或,可以得到一个大于等于原容量且为 2 的幂的最小整数。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
2.2.5 HashMap 容量为什么必须是 2 的幂数 / 哈希过程有用到取模吗
HashMap 最核心的思想就是散列。
散列也就是把一堆元素映射到 N 个桶里。
正常思维是取模运算,但是效率比较低。
如果容量为 2 的幂数,可以通过 e.hash & (cap - 1) 快速得到元素在数组里的下标。
这样设置为 2 的幂数可以使得计算机运算更快,也可以使元素的分布更加均匀。
2.2.6 HashMap 扩容机制
HashMap 使用的是懒加载,Node[] table 一开始是没有加载的,等 put 后才开始加载,也就是说 put 一个新元素后发现容量达到阈值,调用 resize() 。
resize 主要就是把 Cap 容量扩容到两倍,重新声明一个新容量的数组,把旧数组里所有结点 rehash,放入到新数组里。
rehash 公式: e.hash & (newCap - 1) , e.hash & (oldCap * 2 - 1)
可以发现新旧两次计算下表的结果,要么相同,要么就是旧数组下标 + 旧容量,也就是取决于 oldCap 二进制左移多出来的那一位。
2.2.7 HashMap 计算下标公式
e.hash & (cap - 1)
2.2.8 HashMap 底层实现
底层数据结构是由 volatile 修饰的 Node 数组 + 链表 + 红黑树实现,核心思想是用 Synchronized 和 CAS 实现,先 CAS 自旋尝试写入,自旋失败再去用 Synchronized 锁住链表的头节点或者红黑树的根节点。
2.3 List
2.3.1 ArrayList 扩容机制
默认容量 10 。
添加元素前会调用 ensureCapacityInternal() 对容量进行检查判断是否能够添加新元素。
如果容量不够就会调用 grow() 进行扩容,newCap = oldCap + (oldCap >> 1) 。
然后调用 Arrays.copyOf() 把原数组的元素拷贝到新数组上。
2.4 其他
2.4.1 讲讲 ConcurrentModificationException
在使用 for-each 或者 迭代器 对集合进行删除操作时,虚拟机就会报这个异常。
本质上因为集合的 remove 后会对自己的 modCount 修改,但是没有修改迭代器的 expectedModCount,最终进行检查时发现 modCount != expectedModCount,就会抛出异常。
解决方法:
- 使用 CopyOnWriteArrayList
- 在使用 Iterator 迭代的时候使用 Synchronized 或者 Lock 进行同步
2.4.2 讲讲并发容器的原理
- 第一种是直接在对应方法加 Synchronized 进行同步,例如 Vector,HashTable。
- 第二种是基于写时复制的思想去实现的,例如 CopyOnWriteArrayList,CopyOnWriteSet。
写时复制:在写操作的时候会先进行加锁操作,然后获取到成员变量最终存储元素的容器,拷贝一份数据,声明一份容量更大的新容器,把元素转移到新容器里,然后把成员变量存储元素的容器指向这个新容器,最后解锁。在写操作时,其他线程读取的是旧容器里的数据,不会阻塞。
- 第三种就是在第一种的基础上对锁的粒度进行减小,例如 JDK 1.7 的 ConcurrentHashMap 的分段锁,例如 JDK 1.8 ConcurrentHashMap 利用 CAS + Synchronized 对链表结点 / 红黑树根结点进行加锁。
2.4.3 Java 的数据结构
List:ArrayList,LinkedList,Vector
Queue:Queue,PriorityQueue
Set:HashSet,TreeSet
Map:HashMap,ConcurrentHashMap,HashTable
3. JVM
3.1 new Object() 占用多少字节
public class instance {
byte i = 0;
}
如果是 new instance(),要考虑两种情况:
- 开启指针压缩,就是把 Class Pointer 压缩到 4 字节,所以是 16 字节。(对象头 8 + 类指针 4 + byte 1 + 填充 3)
- 不开启指针压缩,是 24 字节。(对象头 8 + 类指针 8 + byte 1 + 填充 7)
参考 :https://zhuanlan.zhihu.com/p/245583335
对象头 :
-Mark Word :HashCode,GC年龄,锁标记,偏向锁线程 ID 等 ,8字节
-Class Pointer:对象指向它所属的类元信息地址,8字节
-Length :数组持有,4字节
实例数据:
-boolean,byte:1字节
-short,char:2字节
-int,float:4字节
-long,double,reference:8字节
对象填充:
保证对象的大小为 8 的整数倍。
3.2 加载一个 class 文件的过程
类加载的过程分为三部分:加载,连接,初始化。
加载:把一个.class 文件装载到内存。
连接:
校验:验证.class 文件的正确性以及安全性。
准备:为静态变量赋值,这个值是默认值。如果有 final 修饰的变量,直接赋值为原来的值。
解析:把符号引用转换为直接引用。
初始化:就是 static 变量修饰的代码块在这一步执行,会为静态变量赋值,这个值是原值。
3.3 讲讲垃圾回收
垃圾回收的话,要先确定哪些对象是可以被回收的对象。
- 引用计数法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。一个对象如果没有任何与之关联的引用,即他们的引用计数都为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。但是该方法解决不了循环引用的问题。
- 可达性分析
为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的 GC roots 对象作为起点搜索。如果在 GC roots 和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
确定是可回收的对象后,就该判断选择什么垃圾回收算法。
- 标记清除算法
最基础的垃圾回收算法,分为两个阶段,标记和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记对象所占用的内存空间。
该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
- 复制算法
为了解决标记清除算法内存碎片化严重的缺陷而被提出的算法。按内存容量将内存划分为相等大小的两块,每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
- 标记整理算法
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象。
- 分代收集算法
是目前大部分 JVM 采用的主流方法,核心思想是根据不同内存区域选择不同的算法。
新生代对象交替频繁,存活率低,使用复制算法。
老年代只需要回收少量对象,使用标记整理算法。
3.4 GC 回收的区域
GC 一般发生在 新生代/老年代/永久代,也就是堆和方法区。
后续方法区改为元空间,转移到直接内存。
所以 1.8 之后 GC 主要发生在堆。
3.5 Object obi = null ; (200行代码 30S GC10次 ) 最后是否被清除
置为 null 后会被标记为可回收的对象,但是并不会马上回收该对象,会先对此对象判断是否有必要执行 finalize 方法,当对象没有覆盖 finalize 方法或 finalize 方法被虚拟机调用过,就会被视为没有必要执行。被判定需要执行的对象会被放在一个队列中进行第二次的标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被回收。
3.6 Java 内存模型
Java 内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。
Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。
Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
这里的工作内存就相当于操作系统中的高速缓存区。
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
volatile 修饰的变量进行变动时,其值会强制刷入主存,其他线程遵从缓存一致性协议,会把新值写入自己的工作内存,实现可见性。
3.7 JVM 内存模型 / 数据分区
共享:
- 堆
所有对象实例和数组都在堆区上分配,堆区是 GC 主要管理的区域。堆区还可以细分为新生代、老年代,新生代还分为一个 Eden 区和两个 Survivor 区。此块内存为所有线程共享区域,当堆中没有足够内存完成实例分配时会抛出OOM异常。
- 方法区
存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
线程私有:
- 程序计数器
程序计数器是一块较小的内存空间,它是当前线程执行字节码的行号指示器,字节码解释工作器就是通过改变这个计数器的值来选取下一条需要执行的指令。它是线程私有的内存,也是唯一一个没有 OOM 异常的区域。
- 虚拟机栈
当前线程运行方法所需要的数据,指令,返回地址。(局部变量表,操作数栈,动态链接,方法出口)
- 本地方法栈
为 Native 方法服务
3.8 OOM 发生区域
除了程序计数器之外都会发生。
3.9 强/软/弱/虚 引用
- 强引用
new 出来的对象,垃圾回收器不会主动回收它,内存不够时虚拟机宁愿抛出 OOM 也不会回收它。
- 软引用
内存空间足够时不会被回收,但是内存空间不足时会回收这些对象的内存。
软引用可以和⼀个引用队列(ReferenceQueue)联合使⽤,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引用队列中。
- 弱引用 WeakReference
无论当前内存空间是否足够,只要被垃圾回收器线程扫描到就会被回收。
- 虚引用
如果一个对象持有虚引用,那么它就和没有任何引用一样,任何时候都可能会被回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
3.10 双亲委派机制
类加载器:
- 启动类加载器(Bootstrap ClassLoader)
负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。
- 扩展类加载器(Extension ClassLoader)
负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类 库。
- 应用程序类加载器(Application ClassLoader)
负责加载用户路径(classpath)上的类库。
- 自定义类加载器
双亲委派:
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中, 只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的 Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证使用不同的类加载器最终得到的都是同样一个 Object 对象。
3.11 JVM 内存分配
- 对象优先在新生代的 eden 区分配
- 大对象直接进入老年代
- 长期存活的对象会进入老年代
- <深入理解虚拟机>:新生代存活 15 个周期的对象进入老年代
- Oracle 官网虚拟机参数:默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6。
Hotspot 遍历所有对象时,按照年龄从⼩到⼤对其所占⽤的⼤⼩进⾏累积,当累积的某个年龄⼤⼩超过了 survivor 区的⼀半时,取这个年龄和 MaxTenuringThreshold 中更⼩的⼀个值,作为新的晋升年龄阈值。
3.12 什么时候触发 GC
- 对象优先在 Eden 中分配,当 Eden 中没有足够空间时,虚拟机将发生一次 Minor GC,Minor GC 非常频繁,而且速度也很快。
- Full GC,发生在老年代的GC,当老年代没有足够的空间时即发生Full GC,发生Full GC的时候一般都伴随一次Minor GC。
- 发生 Minor GC 时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则进行一次 Full GC,如果小于,则查看是否允许担保失败,如果允许,那只会进行一次 Minor GC,如果不允许,则改为进行一次 Full GC。
3.13 讲讲 CMS 工作流程
CMS 是一种以获取最短回收停顿时间为目标的并发收集器,主要是应用于注重用户体验的应用。
CMS 基于 标记-清除算法 实现,主要分为四个步骤:
- 初始标记,暂停其他所有线程,记录下与 GC ROOT 相连的对象,速度很快。
- 并发标记,同时开启 GC 和用户线程,用一个闭包的结构去记录可达对象。但是这个闭包结构并不能保证包含当前所有的可达对象,因为用户线程运行时可能会更新引用域。
- 重新标记,修正并发标记过程中因为用户线程运行而产生标记变动的记录,这一阶段的停顿时间比初始标记长,比并发标记短。
- 并发清除,同时开启 GC 和用户线程,同时对未标记的区域做清扫。
3.14 讲讲 G1
https://www.cnblogs.com/jmcui/p/14165601.html
G1 是面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时还具备高吞吐量的特征。
与 CMS 相比:
1.基于标记-整理算法,不产生内存碎片。
2.可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。
3.15 讲讲复制算法,标记清除算法的内容和区别
复制算法:
把内存分为大小相同的两块,每次只使用一块,使用完就把存活的对象复制到另一块上,然后把使用完的那一块清理掉。
内存利用率低。
标记清除算法:
标记出存活对象,把没标记的对象全部清除。
效率问题,并且会产生大量不连续的内存碎片。
3.16 为什么年轻代用复制算法,老年代用标记清除算法
年轻代主要存储新声明的对象,淘汰率高,复制算法性能消耗比较低。
老年代存储的是长时间存活对象和大对象,对内存要求高,所以不能用复制算法。
4. JUC
4.1 线程池怎么去设计
线程池主要体现的是 池化 的思想,目的是减少线程的频繁创建和销毁,对线程进行统一管理。
主要就是用一个阻塞队列对线程进行存储,利用 lock.newCondition() 对线程的状态进行控制,让队列里的空闲线程进入等待状态,等到需要使用时再唤醒即可。
可以参考 leader-follower 模式,在线程池启动的时候自动产生一个 leader 线程负责等待事件产生,事件发生时会提拔一个 follower 线程为新的 leader,然后原 leader 自己去处理事件,结束后加入 Follower 线程等待队列,可以消除动态内存分配和线程间的数据交换。
4.2 线程的状态
- 初始状态,线程被构建,没有调用 start() 。
- 运行状态,Java 线程把操作系统的就绪和运行两种状态笼统合并在一起,也就是调用 start() 和 调用 run() 归在同一个状态。
- 阻塞状态,线程阻塞于锁。
- 等待状态,线程进入等待状态,需要其他线程做出一些特定动作,例如通知 / 中断。
- 超时等待状态,可以在指定时间自行返回。
- 终止状态,线程执行完毕。
4.3 谈谈对线程的理解
介绍下面两点即可。
- 线程和进程的区别
- 线程的状态
4.4 线程间通信
- synchronized,底层 monitorenter,monitorexit 实现
- wait,notify,notifyAll
- volatile,保证可见性以及禁止指令重排
- lock
- 阻塞队列
- 原子变量,底层 unsafe,内存操作级别
4.5 synchronized 修饰域及原理
- 无法修饰构造方法,构造方法本身就属于线程安全,不存在同步的构造方法这一说法。
- 修饰代码块,同步代码块的实现使用的是 monitorenter 和 monitorexit 指令, monitorenter 指令指明代码块的开始位置,monitorexit 指令指明代码块的结束位置。
执行 monitorenter 指令以及 wait / notify 都是依赖于 monitor 对象,所以必须在同步块/方法中才能调用,否则会抛出异常。
monitor 对象是基于 C++ 实现的,由 ObjectMonitor 实现,每个对象都内置了 ObjectMonitor 对象,所以线程试图获取锁的本质,也就是获取对象监视器 monitor 的持有权。
在执⾏ monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。
- 修饰方法,底层实现使用的是 ACC_SYNCHRONIZED 标识,这个标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。
两者的本质都是对对象监视器 monitor 的获取。
4.6 sleep 和 wait 区别
- 对于 sleep() 方法,我们首先要知道该方法是属于 Thread 类中的。而 wait() 方法,则是属于 Object 类中的。
- sleep() 方法导致了程序暂停执行指定的时间,让出 cpu 给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态。
- 在调用 sleep()方法的过程中,线程不会释放对象锁。
- 而当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify() 方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。
4.7 讲讲线程池
线程池主要体现的是 池化 的思想,目的是减少线程的频繁创建和销毁,对线程进行统一管理。
他的主要特点为:线程复用;控制最大并发数;管理线程。
底层就是 继承重写 Thread 类,在其 start 方法中添加不断循环调用传递过来的 Runnable 对象。 循环方法中不断获取 Runnable 是用 Queue 实现的,在获取下一个 Runnable 之前可以是阻塞的。利用 lock.newCondition() 对线程的状态进行控制,让队列里的空闲线程进入等待状态,需要使用时再唤醒即可。
4.8 阻塞队列和同步队列
阻塞队列:
队列有容量限制,当队列元素已满时,新增加的元素必须等待,或当队列元素取出已空时,也会阻塞等待新的元素。
同步队列:
容量为 0,不存储元素,队列是空的,即每一个 put 操作,必须等待一个take,否则无法继续添加元素。
4.9 终止线程的四种方法
- 正常结束
- 退出标志
- Interrupt
- stop (不推荐),会立马终止线程,导致 try-catch-finnally 代码块没执行,部分资源没关闭,并且会释放子线程所持有的所有锁,破坏数据的一致性。
4.10 讲讲 volatile
volatile 修饰变量,用来确保将变量的更新操作通知到其他线程。
volatile 修饰的变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
特点:
- 变量可见性
- 禁止指令重排
4.11 讲讲 ThreadLocal
ThreadLocal ,线程本地存储。
ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。
主要核心就是 ThreadLocal 静态内部类 ThreadLocalMap,key 为当前的 Thread 对象,值为 Object 对象。
最终的变量放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal,ThreadLocal 只是 ThreadLocalMap 的封装,传递了变量值。
ThreadLocal 类可以通过 Thread.currentThread() 获取当前线程对象,直接通过 getMap(Thread t) 就可以访问到当前线程的 ThreadLocalMap。
需要注意的是,ThreadLocalMap 的 key 为 ThreadLocal 的弱引用,而 value 是强引用。如果 ThreadLocal 没有外部强引用,那么 GC 的时候 key 就会被清理掉,而 value 继续存在,这样就会出现 key 为 null 的 Entry,这个时候就可能产生内存泄漏。
所以,每次使用完 ThreadLocal,都调用它的 remove() 方法,清除数据,防止内存泄漏。
这里提一下,如果 key 为 ThreadLocal 的强引用,那么 ThreadLocal 对象就会一直占用着内存,直到手动清除。
4.12 讲讲 ForkJoinPool
ForkJoinPool 是 ExecutorService 和 Executor 的实现类,如果没有指定线程数量时会设置为当前计算机可用的 CPU 数量。
Runtime.getRuntime().availableProcessors()
主要使用的是 分治法 解决问题,就是把一个大的任务分割为若干互不依赖的子任务。为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。执行完子任务再把结果合并。
核心思想是工作窃取算法,指的是某个空闲线程从其他队列里窃取任务来执行。为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
工作窃取算法的优点是充分利用线程进行并行计算,并减少了线程间的竞争,其缺点是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。
5. IO
5.1 BIO / NIO / AIO
IO 模型都可以分为两个阶段:
- 等待数据准备就绪(区分阻塞)
- 将数据从内核态拷贝到用户态(区分同步)
BIO:Blocking IO,同步阻塞,数据的读取和写入都必须在一个线程内等待其完成。每个连接可以专注于自己的 IO 并且编程模型简单,不用考虑过载,限流等问题。 阻塞 IO 在 IO 执行的时候两个阶段都会被阻塞。
NIO:Non-Blocking IO,同步非阻塞,提供了 Channel,Selector,Buffer 等抽象,⽀持⾯向缓冲的,基于通道的 I/O 操作⽅法。用户进程会不断的问内核数据准备好了没有。
AIO:用户线程完全不需要实际的整个 IO 操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示 IO 操作已经完成,可以直接去使用数据了。IO 操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用 IO 函数进行具体的读写。
6. 流
6.1 讲讲 ParallelStreams
ParallelStream 底层使用了 ForkJoinPool。
具体的流程就是使用把任务分割成多个小任务,分别由多个线程执行,执行结束后再把结果合并返回。
ps:这里可以扩展讲讲 ForkJoinPool。