不同版本JDK安装网站:https://adoptium.net/zh-CN/
继承,封装,多态
面对对象语言的特性:封装、继承、多态。
封装:
封装也叫作信息隐藏或者数据访问保护。
类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
继承:
- 表示类之间的
is-a
关系。- 继承最大的一个好处就是代码复用。
- 假如两个类有一些相同的属性和方法,就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。
- 这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。
多态:
- 子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。
- 多态提高了代码的可扩展性和复用性。
值传递还是引用传递
Java中方法参数传递方式是按值传递。
如果参数是基本类型,传递的是基本类型的数据拷贝。
如果参数是引用类型,因为栈中存的是对象的地址值,所以传递的是该参量所引用的对象在堆中地址值的拷贝。
编译期与运行期
编译期是指把源码交给编译器编译成计算机可执行文件的过程,运行期是指把编译后的文件交给计算机执行,直到程序结束。
在
Java
中就是把.java
文件编译成.class
文件,再把编译后的文件交给JVM
加载执行。
接口和抽象的区别
抽象类带来的很大的作用就是实现代码复用,比如当有多个子类有相同的属性或方法时,我们可以抽象出一个公共的类,然后子类继承这个抽象类,当然,这个公共类可以是普通父类,也可以是抽象类。
- 但是抽象类中的方法可以交给子类去实现,让子类有不同的功能。
接口实现了约定和实现相分离,降低了代码间的耦合性,提高代码的可扩展性。
- 调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。
反射
在Java运行状态时,只要给定类的名字,就能知道这个类的所有信息,可以构造出指定对象,可以调用它的任意一个属性和方法。
- 这种动态获取信息以及动态调用对象的方法的功能是反射机制。
关键字
Final
Final可以用来修饰变量、方法或者类。
修饰变量:
- 这个变量一旦被赋值就不能被修改了,如果尝试给其赋值,会报编译错误。
修饰方法:
- 该方法不可以被重写。
修饰类:
- 这个类不可被继承。
注意:Final修饰对象时,只是引用不可变,而对象本身的内容依然是可以变化的。
- 这一点同样适用于数组。
Static
修饰类变量:
- 如果该变量是 public 的话,表示该变量任何类都可以直接访问,而且无需初始化类,直接使用 类名.static 变量 访问。
修饰方法:
代表该方法和当前类是无关的,任意类都可以直接访问(如果权限是 public 的话)。
被 static 修饰的方法,在类初始化的时候并不会初始化,只有当自己被调用时,才会被执行。
修饰方法块:
- 方法块(静态块)常常用于在类启动之前,初始化一些值。
- 静态块只能调用同样被 static 修饰的变量,并且 static 的变量需要写在静态块的前面,不然编译会报错。
Transient
transient用来修饰类变量,意思是当前变量无需进行序列化。
非静态初始化块(构造代码块):
给对象进行初始化,对象一建立就运行,且优先于构造函数的运行。
非静态初始化块给所有对象进行统一初始化,构造函数只给对应对象初始化。
可以将所有构造函数共性的东西定义在构造代码块中。
静态初始化块:
给类进行初始化,随着类的加载而执行,且只执行一次。
与构造代码块的区别:
- 构造代码块用于初始化对象,每创建一个对象就会被执行一次。
- 静态代码块用于初始化类,随着类的加载而执行,不管创建几个对象,都只执行一次。
- 静态代码块优先于构造代码块的执行。
执行顺序:
所有的静态初始化块都优先执行,其次才是非静态的初始化块和构造函数,它们的执行顺序是:
- 父类的静态初始化块
- 子类的静态初始化块
- 父类的初始化块
- 父类的构造函数
- 子类的初始化块
- 子类的构造函数
Long
Long 自己实现了一种缓存机制,缓存了从 -128 到 127 内的所有 Long 值,如果是这个范围内的 Long 值,就不会初始化,而是从缓存中拿,缓存初始化源码如下:
private static class LongCache {
private LongCache(){}
// 缓存,范围从 -128 到 127,+1 是因为有个 0
static final Long cache[] = new Long[-(-128) + 127 + 1];
// 容器初始化时,进行加载
static {
// 缓存 Long 值,注意这里是 i - 128 ,所以再拿的时候就需要 + 128
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}
String
字符串是一个常量,一旦创建了一个 String 对象,就无法改变它的值,它的内容也就不可能发生变化(不考虑反射这种特殊行为)。
String 具备不变性背后的原因是什么:
public final class String
implements Java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
//...
}
private final
的 char 数组,数组名字叫 value。它存储着字符串的每一位字符,同时 value 数组是被 final 修饰的,这个 value 一旦被赋值,引用就不能修改了。
除了构造函数之外,并没有任何其他方法会修改 value 数组里面的内容,而且 value 的权限是 private,外部的类也访问不到,所以最终使得 value 是不可变的。
String 类是被 final 修饰的,所以这个 String 类是不会被继承的,因此没有任何人可以通过扩展或者覆盖行为来破坏 String 类的不变性。
String 不可变的好处
1、使用字符串常量池。
2、用作 HashMap 的 key。
3、缓存 HashCode。
4、线程安全。
泛型
泛型就是在定义类、接口、方法的时候指定某一种特定类型,让类、接口、方法的使用者来决定具体用哪一种类型的参数。
泛型是在
1.5
引入的,只在编译期做泛型检查,运行期泛型就会消失,称为 泛型擦除,最终类型都会变成Object
。泛型的本质是参数化类型,而类型擦除使得类型参数只存在于编译期,在运行时,
JVM
是并不知道泛型的存在的。
泛型好处
在编译的时候能够检查类型安全,并且所有的强制转换都是自动和隐式的。
类型擦除
public static void main(String[] args) {
List<String> list1=new ArrayList<String>();
List<Integer> list2=new ArrayList<Integer>();
System.out.println(list1.getClass()==list2.getClass());
}
ArrayList<String>
和ArrayList<Integer>
在编译时是不同的类型,但是在编译完成后都被编译器简化成了ArrayList
。
为什么要进行泛型的类型擦除?
主要目的是避免过多的创建类而造成的运行时的过度消耗。
泛型类
public class GenericClass<T>{
//成员变量
private T t;
public void function(T t){
}
public T functionTwo(T t){
//注意,这个不是泛型方法!!!
return t;
}
}
泛型接口
public interface GenericInterface<T> {
public T get();
public void set(T t);
public T delete(T t);
default T defaultFunction(T t){
return t;
}
}
泛型函数
public class GenericFunction {
public <T> void function(T t) {
}
public <T> T functionTwo(T t) {
return t;
}
public <T> String functionThree(T t) {
return "";
}
}
通配符
通配符是为了让Java
泛型支持范围限定。
<?>
:无界通配符,即类型不确定,任意类型。
<? extends T>
:上边界通配符,即?
是继承自T
的任意子类型,遵守只读不写。
<? super T>
:下边界通配符,即?
是T
的任意父类型,遵守只写不读。
<? extends T>
上边界通配符:
- 不作为函数入参,只作为函数返回类型,比如
List<? extends T>
的使用add
函数会编译不通过,get
函数则没问题。
<? super T>
下边界通配符:
- 不作为函数返回类型,只作为函数入参,比如
List<? super T>
的add
函数正常调用,get
函数也没问题,但只会返回Object
,所以意义不大。
NIO
非阻塞模式
使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。
非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
通俗理解:
NIO 是可以做到用一个线程来处理多个操作的。
假设有 10000 个请求过来,根据实际情况,可以分配 50 或者 100 个线程来处理,不像之前的阻塞 IO 那样,非得分配 10000 个。
同步模式
同步与异步是基于应用程序和操作系统处理IO事件所采用的方式:
同步:应用程序要直接参与IO读写的操作。
异步:所有的IO操作交给操作系统去处理,应用程序只需要等待通知。
NIO需要线程来进行数据读写和事件处理,所以是同步模式。
ThreadLocal
ThreadLocal 提供了线程本地变量的实例,它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。
使用场景
ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景,避免了传参,类似于全局变量的概念。
关键属性
// threadLocalHashCode 表示当前 ThreadLocal 的 hashCode,用于计算当前 ThreadLocal 在 ThreadLocalMap 中的索引位置
private final int threadLocalHashCode = nextHashCode();
// 计算 ThreadLocal 的 hashCode 值(就是递增)
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
// static + AtomicInteger 保证了在一台机器中每个 ThreadLocal 的 threadLocalHashCode 是唯一的
// 被 static 修饰非常关键,因为一个线程在处理业务的过程中,ThreadLocalMap 是会被 set 多个 ThreadLocal 的,多个 ThreadLocal 就依靠 threadLocalHashCode 进行区分
private static AtomicInteger nextHashCode = new AtomicInteger();
ThreadLocalMap
ThreadLocalMap 本身就是一个简单的 Map 结构,key 是 ThreadLocal,value 是 ThreadLocal 保存的值,底层是数组的数据结构。
static class ThreadLocalMap {
// 数组中的每个节点值,WeakReference 是弱引用,当没有引用指向时,会直接被回收
static class Entry extends WeakReference<ThreadLocal<?>> {
// 当前 ThreadLocal 关联的值
Object value;
// WeakReference 的引用 referent 就是 ThreadLocal
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 数组的初始化大小
private static final int INITIAL_CAPACITY = 16;
// 存储 ThreadLocal 的数组
private Entry[] table;
// 扩容的阈值,默认是数组大小的三分之二
private int threshold;
}
ThreadLocal 是如何做到线程之间数据隔离的
主要因为是 ThreadLocalMap 是线程的属性。
ThreadLocals.ThreadLocalMap
和InheritableThreadLocals.ThreadLocalMap
分别是线程的属性,所以每个线程的 ThreadLocals 都是隔离独享的。父线程在创建子线程的情况下,会拷贝 inheritableThreadLocals 的值,但不会拷贝 threadLocals 的值。
set 方法
// set 操作每个线程都是串行的,不会有线程安全的问题
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// 当前 thradLocal 之前有设置值,直接设置,否则初始化
if (map != null)
map.set(this, value);
// 初始化ThreadLocalMap
else
createMap(t, value);
}
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 计算 key 在数组中的下标,其实就是 ThreadLocal 的 hashCode 和数组大小-1取余
int i = key.threadLocalHashCode & (len-1);
// 整体策略:查看 i 索引位置有没有值,有值的话,索引位置 + 1,直到找到没有值的位置
// 这种解决 hash 冲突的策略,也导致了其在 get 时查找策略有所不同,体现在 getEntryAfterMiss 中
for (Entry e = tab[i];
e != null;
// nextIndex 就是让在不超过数组长度的基础上,把数组的索引位置 + 1
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 找到内存地址一样的 ThreadLocal,直接替换
if (k == key) {
e.value = value;
return;
}
// 当前 key 是 null,说明 ThreadLocal 被清理了,直接替换掉
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 当前 i 位置是无值的,可以被当前 thradLocal 使用
tab[i] = new Entry(key, value);
int sz = ++size;
// 当数组大小大于等于扩容阈值(数组大小的三分之二)时,进行扩容
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
1、通过递增的 AtomicInteger 作为 ThreadLocal 的 hashCode 的;
2、计算数组索引位置的公式是:
- hashCode 取模数组大小,由于 hashCode 不断自增,所以不同的 hashCode 大概率上会计算到同一个数组的索引位置(在实际项目中,ThreadLocal 都很少,基本上不会冲突)
3、通过 hashCode 计算的索引位置 i 处如果已经有值了,会从 i 开始,通过 +1 不断的往后寻找,直到找到索引位置为空的地方,把当前 ThreadLocal 作为 key 放进去。
get 方法
public T get() {
// 因为 threadLocal 属于线程的属性,所以需要先把当前线程拿出来
Thread t = Thread.currentThread();
// 从线程中拿到 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 从 map 中拿到 entry,由于 ThreadLocalMap 在 set 时的 hash 冲突的策略不同,导致拿的时候逻辑也不太一样
ThreadLocalMap.Entry e = map.getEntry(this);
// 如果不为空,读取当前 ThreadLocal 中保存的值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 否则给当前线程的 ThreadLocal 初始化,并返回初始值 null
return setInitialValue();
}
// 自旋 i+1,直到找到为止
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 在大量使用不同 key 的 ThreadLocal 时,其实还蛮耗性能的
while (e != null) {
ThreadLocal<?> k = e.get();
// 内存地址一样,表示找到了
if (k == key)
return e;
// 删除没用的 key
if (k == null)
expungeStaleEntry(i);
// 继续使索引位置 + 1
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
扩容
ThreadLocalMap 中的 ThreadLocal 的个数超过阈值时,ThreadLocalMap 就要开始扩容了:
//扩容
private void resize() {
// 拿出旧的数组
Entry[] oldTab = table;
int oldLen = oldTab.length;
// 新数组的大小为老数组的两倍
int newLen = oldLen * 2;
// 初始化新数组
Entry[] newTab = new Entry[newLen];
int count = 0;
// 老数组的值拷贝到新数组上
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
// 计算 ThreadLocal 在新数组中的位置
int h = k.threadLocalHashCode & (newLen - 1);
// 如果索引 h 的位置值不为空,往后+1,直到找到值为空的索引位置
while (newTab[h] != null)
h = nextIndex(h, newLen);
// 给新数组赋值
newTab[h] = e;
count++;
}
}
}
// 给新数组初始化下次扩容阈值,为数组长度的三分之二
setThreshold(newLen);
size = count;
table = newTab;
}
1、扩容后数组大小是原来数组的两倍。
2、扩容时是没有线程安全问题的,因为 ThreadLocalMap 是线程的一个属性,一个线程同一时刻只能对 ThreadLocalMap 进行操作,因为同一个线程执行业务逻辑必然是串行的,那么操作 ThreadLocalMap 必然也是串行的。
内存泄漏
ThreadLocalMap的每个Entry都是一个对key的弱引用,同时每个Entry都包含了一个对value的强引用。
- 正常情况下,当线程终止,保存在ThreadLocal里的value会被垃圾回收,因为没有任何强引用了。
但是,如果线程不终止(比如线程需要保持很久),那么key对应的value就不能被回收,因为有以下的调用链:
Thread ---> ThreadLocalMap ---> Entry(key为null) ---> value
因为value和Thread之间还存在这个强引用链路,所以导致value无法回收,就可能会出现OOM。
JDK已经考虑到了这个问题,所以在set,remove,rehash方法中会扫描key为null的Entry,并把对应的value设置为null,这样value对象就可以被回收。
如何避免内存泄露
调用remove方法,就会删除对应的Entry对象,可以避兔内存泄漏,所以使用完ThreadLocal之后,应该调用remove方法。
ThreadLocal<String> localName = new ThreadLocal();
try {
localName.set("月伴飞鱼");
// 其它业务逻辑
} finally {
localName.remove();
}
空指针问题
ThreadLocal在进行get之前,必须先set,否则会报空指针异常。
public class ThreadLocalNPE {
ThreadLocal<Long> longThreadLocal = new ThreadLocal<>();
public void set() {
longThreadLocal.set(Thread.currentThread().getId());
}
//拆装箱问题
public Long get() {//long:NullPointerException
Long res = longThreadLocal.get();
return res;
}
public static void main(String[] args) {
ThreadLocalNPE threadLocalNPE = new ThreadLocalNPE();
System.out.println(threadLocalNPE.get());//NullPointerException
}
}
如果在每个线程中
ThreadLocal.set()
进去的东西本来就是多线程共享的同一个对象,比如static对象,那么多个线程的ThreadLocal.get()
取得的还是这个共享对象本身,还是有并发访问问题。
InheritableThreadLocal
InheritableThreadLocal
解决父子线程变量传递的问题。
- 如果我在后面改了父线程,子线程不会更新它的本地变量
Map
。这个
ThreadLocalMap
的局部变量,实际作用是在子线程创建的时候:
- 父线程会把
threadLocal
拷贝到子线程中。
public class InheritableThreadLocalSolution {
// 使用 ThreadLocal 存储当前线程的用户名
private static final ThreadLocal<String> userNameThreadLocal = new ThreadLocal<>();
// 使用 InheritableThreadLocal 存储当前线程的用户名,以便子线程可以继承
private static final InheritableThreadLocal<String> inheritableUserNameThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) {
// 设置父线程的 ThreadLocal 值
userNameThreadLocal.set("Parent Thread");
inheritableUserNameThreadLocal.set("Parent Thread (Inheritable)");
// 创建子线程并启动
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
// 尝试从子线程中获取父线程的 ThreadLocal 值(通常获取不到)
System.out.println("From child thread (ThreadLocal): " + userNameThreadLocal.get());
// 尝试从子线程中获取父线程的 InheritableThreadLocal 值
System.out.println("From child thread (InheritableThreadLocal): " + inheritableUserNameThreadLocal.get());
});
executorService.shutdown();
}
}
InheritableThreadLocal的内存泄漏
当移除父线程的
threadlocal
,子线程的threadlocal
并不会消失。
- 并且通常来讲子线程是放线程池管理的,不会随着父线程的消失而消失。
所以子线程的
threadlocal
就一直存在,但是已经用不到了,这就造成了内存泄漏了。
public class InheritableThreadLocalSolution {
private static final InheritableThreadLocal<String> inheritableUserNameThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
inheritableUserNameThreadLocal.set("Parent Thread (Inheritable)");
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("From child thread (InheritableThreadLocal): " + inheritableUserNameThreadLocal.get());
});
TimeUnit.SECONDS.sleep(5);
inheritableUserNameThreadLocal.remove();
// 提交第二个任务,看看子线程是否还会获得 inheritableThreadLocal的值
// 结果:会的
executorService.submit(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 父线程以及移除了,但是子线程仍然能获取到
System.out.println("second From child thread (InheritableThreadLocal): " + inheritableUserNameThreadLocal.get());
});
TimeUnit.SECONDS.sleep(10);
executorService.shutdown();
}
}
TransmittableThreadLocal
项目地址:https://gitee.com/mirrors/transmittable-thread-local
对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的。
- 这时父子线程关系的
ThreadLocal
值传递已经没有意义
- 需要把 任务提交给线程池时的
ThreadLocal
值传递到 任务执行时。
TransmittableThreadLocal
解决线程池变量丢失问题。
- 线程池会复用之前的线程,导致父线程的本地变量更新之后,之前创建的子线程拿不到这个值。
get/set
方法中完成了TransmittableThreadLocal
的注册
- 然后在执行run方法的时候通过
TtlRunnable
进行了方法包装在调用之前进行快照形成,并应用快照到当前线程中
最后在线程执行结束之后,run方法内部对线程局部变量做的修改则会被还原。
通过将线程封装成
TtlRunnable
,然后通过快照还有hold
一个总收集变量来解决。
集合类
HashMap
HashMap 底层的数据结构主要是:数组 + 链表 + 红黑树。
其中当链表的长度大于等于 8 时,链表会转化成红黑树,当红黑树的大小小于等于 6 时,红黑树会转化成链表。
常见属性
//初始容量为 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//桶上的链表长度大于等于8时,链表转化成红黑树
static final int TREEIFY_THRESHOLD = 8;
//桶上的红黑树大小小于等于6时,红黑树转化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//当数组容量大于 64 时,链表才会转化成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//记录迭代过程中 HashMap 结构是否发生变化,如果有变化,迭代时会 fail-fast
transient int modCount;
//HashMap 的实际大小,可能不准(因为当你拿到这个值的时候,可能又发生了变化)
transient int size;
//存放数据的数组
transient Node<K,V>[] table;
// 扩容的门槛,有两种情况
// 如果初始化时,给定数组大小的话,通过 tableSizeFor 方法计算,数组大小永远接近于 2 的幂次方,比如你给定初始化大小 19,实际上初始化大小为 32,为 2 的 5 次方。
// 如果是通过 resize 方法进行扩容,大小 = 数组容量 * 0.75
int threshold;
//链表的节点
static class Node<K,V> implements Map.Entry<K,V> {
//红黑树的节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
新增
1、空数组有无初始化,没有的话初始化。
2、如果通过 key 的 hash 能够直接找到值,跳转到 6,否则到 3。
3、如果 hash 冲突,两种解决方案:链表 or 红黑树。
4、如果是链表,递归循环,把新元素追加到队尾。
5、如果是红黑树,调用红黑树新增的方法。
6、通过 2、4、5 将新元素追加成功,再根据 onlyIfAbsent 判断是否需要覆盖。
7、判断是否需要扩容,需要扩容进行扩容,结束。
// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// n 表示数组的长度,i 为数组索引下标,p 为 i 下标位置的 Node 值
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果数组为空,使用 resize 方法初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果当前索引位置是空的,直接生成新的节点在当前索引位置上
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果当前索引位置有值的处理方法,即我们常说的如何解决 hash 冲突
else {
// e 当前节点的临时变量
Node<K,V> e; K k;
// 如果 key 的 hash 和值都相等,直接把当前下标位置的 Node 值赋值给临时变量
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果是红黑树,使用红黑树的方式新增
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 是个链表,把新节点放到链表的尾端
else {
// 自旋
for (int binCount = 0; ; ++binCount) {
// e = p.next 表示从头开始,遍历链表
// p.next == null 表明 p 是链表的尾节点
if ((e = p.next) == null) {
// 把新节点放到链表的尾部
p.next = newNode(hash, key, value, null);
// 当链表的长度大于等于 8 时,链表转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 链表遍历过程中,发现有元素和新增的元素相等,结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//更改循环的当前元素,使 p 在遍历过程中,一直往后移动。
p = e;
}
}
// 说明新节点的新增位置已经找到了
if (e != null) {
V oldValue = e.value;
// 当 onlyIfAbsent 为 false 时,才会覆盖值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回老值
return oldValue;
}
}
// 记录 HashMap 的数据结构发生了变化
++modCount;
//如果 HashMap 的实际大小大于扩容的门槛,开始扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
链表的新增
当链表长度大于等于 8 时,此时的链表就会转化成红黑树,转化的方法是:treeifyBin,此方法有一个判断,当链表长度大于等于 8,并且整个数组大小大于 64 时,才会转成红黑树,当数组大小小于 64 时,只会触发扩容,不会转化成红黑树。
JDK1.7中链表插入采用的是头插法,JDK1.8中插入使用的是尾插法。
- 头插法:扩容时,扩容的逻辑会导致节点互相引用,导致死循环。
为什么是8?
链表查询的时间复杂度是
O(n)
,红黑树的查询复杂度是O(log(n))
。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗。
在考虑设计 8 这个值的时候,参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一,所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让 HashMap 仍然有较高的查询性能,所以让链表转化成红黑树。
红黑树新增节点过程
1、首先判断新增的节点在红黑树上是不是已经存在:
如果节点没有实现 Comparable 接口,使用 equals 进行判断。
如果节点自己实现了 Comparable 接口,使用 compareTo 进行判断。
2、新增的节点如果已经在红黑树上,直接返回;不在的话,判断新增节点是在当前节点的左边还是右边,左边值小,右边值大。
3、自旋递归 1 和 2 步,直到当前节点的左边或者右边的节点为空时,停止自旋,当前节点即为我们新增节点的父节点。
4、把新增节点放到当前节点的左边或右边为空的地方,并于当前节点建立父子节点关系。
5、进行着色和旋转,结束。
扩容机制
扩容阈值:
阈值 = 容量 x 负载因子
,假设当前HashMap
的容量是 16,负载因子是默认值 0.75,当 size 到达16 x 0.75=
12 的时候,就会触发扩容。
查找
根据 hash 算法定位数组的索引位置,equals 判断当前节点是否是我们需要寻找的 key,是的话直接返回,不是的话往下。
判断当前节点有无 next 节点,有的话判断是链表类型,还是红黑树类型。
分别走链表和红黑树不同类型的查找方法。
// 采用自旋方式从链表中查找 key,e 初始为为链表的头节点
do {
// 如果当前节点 hash 等于 key 的 hash,并且 equals 相等,当前节点就是我们要找的节点
// 当 hash 冲突时,同一个 hash 值上是一个链表的时候,我们是通过 equals 方法来比较 key 是否相等的
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
// 否则,把当前节点的下一个节点拿出来继续寻找
} while ((e = e.next) != null);
HashMap为什么是线程不安全的
同时 put 碰撞导致数据丢失。
- 多个线程同时使用 put 来添加元素,而且两个 put 的 key 是一样的,它们发生了碰撞,这样最终就只会保留一个数据。
可见性问题无法保证。
- 如果线程 1 给某个 key 放入了一个新值,那么线程 2 在获取对应的 key 的值的时候,它的可见性是无法保证的。
死循环造成 CPU 100%:
- 扩容的时候,会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,如果两个线程同时反转的话,便可能形成一个循环。
ArrayList
ArrayList整体架构是一个数组结构:
基本概念:
DEFAULT_CAPACITY
:表示数组的初始大小,默认是 10。size:表示当前数组的大小,类型是 int,没有使用 volatile 修饰,非线程安全。
modCount:统计当前数组被修改的版本次数,数组结构有变动,就会加 1。
初始化
ArrayList无参构造器初始化时,默认大小是空数组,并不是默认的
DEFAULT_CAPACITY
10,10 是在第一次 add 时扩容的值。
新增
判断是否需要扩容,如果需要则执行扩容操作。
赋值新元素。
public boolean add(E e) {
// 确保数组大小是否足够,不够则执行扩容,size 为当前数组的大小
ensureCapacityInternal(size + 1); // Increments modCount!!
// 直接赋值
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
// 计算 capacity
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 如果数组为空,则返回默认容量(10) 和最小容量(比如初始化 List add 一个 element 时 minCapacity 为 1)之间的 max 值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
// 确保容量足够
private void ensureExplicitCapacity(int minCapacity) {
// 记录数组被修改
modCount++;
// 如果我们期望的最小容量大于目前数组的长度,则执行扩容操作
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 扩容,并把现有数据拷贝到新的数组中去
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 新的容量为旧的容量的 1.5 倍(capacity >> 1 ==> capacity / 2)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果新的容量小于我们期望值 minCapacity,则将新的容量值赋值为期望值
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新的容量大于 JVM 所能分配的数组的最大值,那么就用 Integer 的最大值
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 通过复制进行扩容
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
扩容的规则并不是翻倍,而是 原来容量大小 + 原来容量大小的一半(原来容量的 1.5 倍)。
ArrayList 中数组的最大容量是
Integer.MAX_VALUE
,超过这个值,JVM 就不会给数组分配内存空间了。新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的。
扩容
扩容会先新建一个符合我们预期容量
newCapacity
的新数组,然后把旧数组的数据拷贝过去。
public static int[] copyOf(int[] original, int newLength) {
int[] copy = new int[newLength];
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
线程安全
只有当
ArrayList
作为共享变量时,才会有线程安全问题,当ArrayList
是方法内部局部变量时,是没有线程安全问题的。本质是因为 ArrayList 自身的 elementData、size、modCount 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见的(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况。
ArrayList和LinkedList使用不当,性能差距会如此之大
LinkedList
LinkedList 适用于要求有顺序、并且会按照顺序进行迭代的场景,底层数据结构是一个双向链表:
链表每个节点我们叫做 Node,Node 有 prev 属性,代表前一个节点的位置,next 属性,代表后一个节点的位置。
first 是双向链表的头节点,它的前一个节点是 null。
last 是双向链表的尾节点,它的后一个节点是 null。
当链表中没有数据时,first 和 last 是同一个节点,前后指向都是 null。
因为是个双向链表,只要机器内存足够强大,是没有大小限制的。
transient Node<E> first;//第一个节点
transient Node<E> last;//最后一个节点
private static class Node<E> {
E item;// 节点值
Node<E> next; // 指向的下一个节点
Node<E> prev; // 指向的前一个节点
// 初始化参数顺序分别是:前一个节点、本身节点值、后一个节点
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
追加(新增)
追加节点时,add 方法默认是从尾部开始追加,addFirst 方法是从头部开始追加。
尾部追加节点比较简单,只需要简单地把指向位置修改下即可。
// 从尾部开始追加节点
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
// 把尾节点数据暂存
final Node<E> l = last;
// 新建新的节点,初始化入参含义:
// l 是新节点的前一个节点,当前值是尾节点值
// e 表示当前新增节点,当前新增节点后一个节点是 null
final Node<E> newNode = new Node<>(l, e, null);
// 新建节点追加到尾部
last = newNode;
//如果链表为空(l 是尾节点,尾节点为空,链表即空),头部和尾部是同一个节点,都是新建的节点
if (l == null)
first = newNode;
//否则把前尾节点的下一个节点,指向当前尾节点。
else
l.next = newNode;
//大小和版本更改
size++;
modCount++;
}
节点删除
节点删除的方式和追加类似,可以选择从头部删除,也可以选择从尾部删除,删除操作会把节点的值,前后指向节点都置为 null,帮助 GC 进行回收。
链表结构的节点新增、删除都非常简单,仅仅把前后节点的指向修改下就好了,所以 LinkedList 新增和删除速度很快。
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
//从头删除节点 f 是链表头节点
private E unlinkFirst(Node<E> f) {
// 拿出头节点的值,作为方法的返回值
final E element = f.item;
// 拿出头节点的下一个节点
final Node<E> next = f.next;
//帮助 GC 回收头节点
f.item = null;
f.next = null;
// 头节点的下一个节点成为头节点
first = next;
//如果 next 为空,表明链表为空
if (next == null)
last = null;
//链表不为空,头节点的前一个节点指向 null
else
next.prev = null;
//修改链表大小和版本
size--;
modCount++;
return element;
}
节点查询
链表查询某一个节点是比较慢的,需要挨个循环查找。
LinkedList 并没有采用从头循环到尾的做法,而是采取了简单二分法,首先看看 index 是在链表的前半部分,还是后半部分。
如果是前半部分,就从头开始寻找,反之亦然。通过这种方式,使循环的次数至少降低了一半,提高了查找的性能。
// 根据链表索引位置查询节点
Node<E> node(int index) {
// 如果 index 处于队列的前半部分,从头开始找,size >> 1 是 size 除以 2 的意思。
if (index < (size >> 1)) {
Node<E> x = first;
// 直到 for 循环到 index 的前一个 node 停止
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {// 如果 index 处于队列的后半部分,从尾开始找
Node<E> x = last;
// 直到 for 循环到 index 的后一个 node 停止
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
HashSet
HashSet原理:使用的就是组合 HashMap。
- 把 HashMap 当作自己的一个局部变量。
// 把 HashMap 组合进来,key 是 Hashset 的 key,value 是下面的 PRESENT
private transient HashMap<E,Object> map;
// HashMap 中的 value
private static final Object PRESENT = new Object();
1、在使用 HashSet 时,比如 add 方法,只有一个入参,但组合的 Map 的 add 方法却有 key,value 两个入参,相对应上 Map 的 key 就是我们 add 的入参,value 就是第二行代码中的 PRESENT,用一个默认值 PRESENT 来代替 Map 的 Value;
2、如果 HashSet 是被共享的,当多个线程访问的时候,就会有线程安全问题,因为在后续的所有操作中,并没有加锁。
TreeSet
TreeSet底层组合的是 TreeMap,所以继承了 TreeMap key 能够排序的功能,迭代的时候,也可以按照 key 的排序顺序进行迭代。
TreeMap
TreeMap 底层的数据结构就是红黑树,和 HashMap 的红黑树结构一样。
不同的是:TreeMap 利用了红黑树左节点小,右节点大的性质,根据 key 进行排序,使每个元素能够插入到红黑树大小适当的位置,维护了 key 的大小关系,适用于 key 需要排序的场景。
因为底层使用的是平衡红黑树的结构,所以 containsKey、get、put、remove 等方法的时间复杂度都是
log(n)
。
TreeMap 常见的属性有:
//比较器,如果外部有传进来 Comparator 比较器,首先用外部的
//如果外部比较器为空,则使用 key 自己实现的 Comparable#compareTo 方法
//比较手段和上面日常工作中的比较 demo 是一致的
private final Comparator<? super K> comparator;
//红黑树的根节点
private transient Entry<K,V> root;
//红黑树的已有元素大小
private transient int size = 0;
//树结构变化的版本号,用于迭代过程中的快速失败场景
private transient int modCount = 0;
//红黑树的节点
static final class Entry<K,V> implements Map.Entry<K,V> {}
新增节点
判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点:
Entry<K,V> t = root;
//红黑树根节点为空,直接新建
if (t == null) {
// compare 方法限制了 key 不能为 null
compare(key, key); // type (and possibly null) check
// 成为根节点
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点:
Comparator<? super K> cpr = comparator;
if (cpr != null) {
//自旋找到 key 应该新增的位置,就是应该挂载那个节点的头上
do {
//一次循环结束时,parent 就是上次比过的对象
parent = t;
// 通过 compare 来比较 key 的大小
cmp = cpr.compare(key, t.key);
//key 小于 t,把 t 左边的值赋予 t,因为红黑树左边的值比较小,循环再比
if (cmp < 0)
t = t.left;
//key 大于 t,把 t 右边的值赋予 t,因为红黑树右边的值比较大,循环再比
else if (cmp > 0)
t = t.right;
//如果相等的话,直接覆盖原值
else
return t.setValue(value);
// t 为空,说明已经到叶子节点了
} while (t != null);
}
在父节点的左边或右边插入新增节点:
//cmp 代表最后一次对比的大小,小于 0 ,代表 e 在上一节点的左边
if (cmp < 0)
parent.left = e;
//cmp 代表最后一次对比的大小,大于 0 ,代表 e 在上一节点的右边,相等的情况第二步已经处理了。
else
parent.right = e;
1、着色旋转,达到平衡,结束。
2、新增节点时,就是利用了红黑树左小右大的特性,从根节点不断往下查找,直到找到节点是 null 为止,节点为 null 说明到达了叶子结点。
3、查找过程中,发现 key 值已经存在,直接覆盖。
4、TreeMap 是禁止 key 是 null 值的。
LinkedHashMap
LinkedHashMap 是继承 HashMap 的,所以它拥有 HashMap 的所有特性,再此基础上,还提供了两大特性:
- 按照插入顺序进行访问。
- 实现了访问最少最先删除功能,其目的是把很久都没有访问的 key 自动删除。
LinkedHashMap链表结构
// 链表头
transient LinkedHashMap.Entry<K,V> head;
// 链表尾
transient LinkedHashMap.Entry<K,V> tail;
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// 控制两种访问模式的字段,默认 false
// true 按照访问顺序,会把经常访问的 key 放到队尾
// false 按照插入顺序提供访问
final boolean accessOrder;
LinkedHashMap 的数据结构很像是把 LinkedList 的每个元素换成了 HashMap 的 Node,像是两者的结合体,也正是因为增加了这些结构,从而能把 Map 的元素都串联起来,形成一个链表,而链表就可以保证顺序了,就可以维护元素插入进来的顺序。
如何按照顺序新增
LinkedHashMap 初始化时,默认 accessOrder 为 false,就是会按照插入顺序提供访问,插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用的 newNode/newTreeNode 和 afterNodeAccess 方法。
newNode/newTreeNode 方法,控制新增节点追加到链表的尾部,这样每次新节点都追加到尾部,即可保证插入顺序了。
// 新增节点,并追加到链表的尾部
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 新增节点
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
// 追加到链表的尾部
linkNodeLast(p);
return p;
}
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
// 新增节点等于位节点
tail = p;
// last 为空,说明链表为空,首尾节点相等
if (last == null)
head = p;
// 链表有数据,直接建立新增节点和上个尾节点之间的前后关系即可
else {
p.before = last;
last.after = p;
}
}
LinkedHashMap 通过新增头节点、尾节点,给每个节点增加 before、after 属性,每次新增时,都把节点追加到尾节点等手段,在新增的时候,就已经维护了按照插入顺序的链表结构了。
IO模型
同步阻塞
用户空间的应用程序执行一个系统调用,这会导致应用程序阻塞,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞,不能处理别的网络IO。
同步非阻塞
非阻塞的系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。
进程在返回之后,可以干点别的事情,然后再发起系统调用。
重复上面的过程,循环往复的进行系统调用,这个过程通常被称之为轮询。
轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。
需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
IO多路复用
IO多路复用,这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个IO条件就绪了,就通知进程。
IO复用的实现方式目前主要有Select、Poll和Epoll。
信号驱动
首先允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。
此种IO方式存在的一个很大的问题:
- Linux中信号队列是有限制的,如果超过这个数字问题就无法读取数据。
异步非阻塞
当用户线程进行了系统调用,立刻就可以开始去做其它的事,用户线程不阻塞。
- 准备数据:当内核一直等到数据准备好了,它就会将数据从内核内核缓冲区,拷贝到用户缓冲区。
- 内核会给用户线程发送一个信号,或者回调用户线程注册的回调接口,告诉用户线程操作完成了。
- 用户线程读取用户缓冲区的数据,完成后续的业务操作。
信号驱动IO,异步IO的主要区别在于:
- 信号驱动由内核告诉我们何时可以开始一个IO操作(数据在内核缓冲区中)。
- 而异步IO则由内核通知IO操作何时已经完成(数据已经在用户空间中)。
BIO,NIO,AIO的区别:
BIO:
- 同步并阻塞,服务实现模式为一个连接对应一个线程,即客户端发送一个连接,服务端要有一个线程来处理。
- 如果连接多了,线程数量不够,就只能等待,即会发生阻塞。
- 适用连接数目比较小且固定的架构。
NIO:
- 同步非阻塞,服务实现模式是一个线程可以处理多个连接,即客户端发送的连接都会注册到多路复用器上,然后进行轮询连接,有I/O请求就处理。
- 适用连接数目多且连接比较短的架构,如:聊天服务器,弹幕系统等,编程比较复杂。
AIO:
- 异步非阻塞,引入了异步通道,采用的是Proactor模式。
- 适用连接数目多且连接长的架构,如相册服务器。
SPI机制
SPI 全称为 Service Provider Interface,是一种服务发现机制。
它通过在ClassPath路径下的
META-INF/services
文件夹查找文件,自动加载文件里所定义的类。这一机制为很多框架扩展提供了可能,比如在Dubbo、JDBC中都使用到了SPI机制。
具体使用
定义一个接口,SPIService
public interface SPIService {
void execute();
}
定义两个实现类
public class SpiImpl1 implements SPIService{
public void execute() {
System.out.println("SpiImpl1.execute()");
}
}
public class SpiImpl2 implements SPIService{
public void execute() {
System.out.println("SpiImpl2.execute()");
}
}
在ClassPath路径下配置添加一个文件。
文件名字是接口的全限定类名,内容是实现类的全限定类名,多个实现类用换行符分隔。
com.viewscenes.netsupervisor.spi.SpiImpl1
com.viewscenes.netsupervisor.spi.SpiImpl2
通过
ServiceLoader.load或者Service.providers
方法拿到实现类的实例。其中,
Service.providers
包位于sun.misc.Service
,而ServiceLoader.load
包位于java.util.ServiceLoader
。
public class Test {
public static void main(String[] args) {
Iterator<SPIService> providers = Service.providers(SPIService.class);
ServiceLoader<SPIService> load = ServiceLoader.load(SPIService.class);
while(providers.hasNext()) {
SPIService ser = providers.next();
ser.execute();
}
System.out.println("--------------------------------");
Iterator<SPIService> iterator = load.iterator();
while(iterator.hasNext()) {
SPIService ser = iterator.next();
ser.execute();
}
}
}
基本原理:通过反射的方式,创建实现类的实例并返回。
新特性
JDK 17
Sealed类:
Sealed类是一种新的类修饰符,用于限制类的继承
- 可以控制哪些类可以继承自它,这样可以使得代码更加安全、可维护
Pattern Matching for Switch:
新的Switch语法,可以用于模式匹配
- 可以根据不同的模式执行不同的操作,从而使得代码更加简洁、易读、易维护
可以减少代码量,避免出现大量的
if-else
语句
改进的垃圾回收器(ZGC(新型垃圾收集器)):
改进了垃圾回收器,提高了垃圾回收的效率和吞吐量
- 可以更加高效地回收内存,从而提高应用程序的性能和响应速度
风格的内存管理:
引入了C++风格的内存管理,包括对堆内存分配的优化和对垃圾回收的改进
- C++风格的内存管理可以使得Java应用程序更加高效,从而提高应用程序的性能和响应速度
JDK21
序列化集合接口:
新增序列集合接口
SequencedCollection
- 常用的
ArrayList
、LinkedList
等都实现了这个接口
ZGC增加分代:
增加了分代功能,比如
CMS
收集器区分老年代和年轻代
- 这样一来,可以更频繁的回收年轻代
虚拟线程(协程):
虚拟线程可以看作是一种用户级线程,与操作系统的线程或进程不同
- 它是由编程语言或库提供的,而不是由操作系统管理的