Java基础总结!
Java基础总结!
月伴飞鱼不同版本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 值,就不会初始化,而是从缓存中拿,缓存初始化源码如下:
1 | private static class LongCache { |
String
字符串是一个常量,一旦创建了一个 String 对象,就无法改变它的值,它的内容也就不可能发生变化(不考虑反射这种特殊行为)。
String 具备不变性背后的原因是什么:
1 | public final class String |
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
是并不知道泛型的存在的。
泛型好处
在编译的时候能够检查类型安全,并且所有的强制转换都是自动和隐式的。
类型擦除
1 | public static void main(String[] args) { |
ArrayList<String>
和ArrayList<Integer>
在编译时是不同的类型,但是在编译完成后都被编译器简化成了ArrayList
。
为什么要进行泛型的类型擦除?
主要目的是避免过多的创建类而造成的运行时的过度消耗。
泛型类
1 | public class GenericClass<T>{ |
泛型接口
1 | public interface GenericInterface<T> { |
泛型函数
1 | public class GenericFunction { |
通配符
通配符是为了让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
用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景,避免了传参。
- 类似于全局变量的概念。
用户登录态上下文:
- 通过
TheadLocal
封装用户公共的上下文信息,可以将身份鉴定、权限等一系列公用内容统一处理,服务层直接应用。
关键属性
1 | // threadLocalHashCode 表示当前 ThreadLocal 的 hashCode,用于计算当前 ThreadLocal 在 ThreadLocalMap 中的索引位置 |
ThreadLocalMap
ThreadLocalMap
本身就是一个简单的Map
结构。
Key
是ThreadLocal
,Value
是ThreadLocal
保存的值,底层是数组的数据结构。
1 | static class ThreadLocalMap { |
ThreadLocal 是如何做到线程之间数据隔离的
主要因为是
ThreadLocalMap
是线程的属性。
ThreadLocals.ThreadLocalMap
和InheritableThreadLocals.ThreadLocalMap
分别是线程的属性。
- 所以每个线程的
ThreadLocals
都是隔离独享的。父线程在创建子线程的情况下,会拷贝
inheritableThreadLocals
的值,但不会拷贝threadLocals
的值。
set 方法
1 | // set 操作每个线程都是串行的,不会有线程安全的问题 |
1 | private void set(ThreadLocal<?> key, Object value) { |
通过递增的
AtomicInteger
作为ThreadLocal
的hashCode
的。计算数组索引位置的公式是:
hashCode
取模数组大小,由于hashCode
不断自增。- 所以不同的
hashCode
大概率上会计算到同一个数组的索引位置(在实际项目中,ThreadLocal
都很少,基本上不会冲突)通过
hashCode
计算的索引位置 i 处如果已经有值了,会从 i 开始,通过 +1 不断的往后寻找。
- 直到找到索引位置为空的地方,把当前
ThreadLocal
作为key
放进去。
get 方法
1 | public T get() { |
1 | // 自旋 i+1,直到找到为止 |
扩容
ThreadLocalMap
中的ThreadLocal
的个数超过阈值时,ThreadLocalMap
就要开始扩容了:
1 | //扩容 |
扩容后数组大小是原来数组的两倍。
扩容时是没有线程安全问题的,因为
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方法。
1 | ThreadLocal<String> localName = new ThreadLocal(); |
空指针问题
ThreadLocal在进行get之前,必须先set,否则会报空指针异常。
1 | public class ThreadLocalNPE { |
如果在每个线程中
ThreadLocal.set()
进去的东西本来就是多线程共享的同一个对象,比如static对象。那么多个线程的
ThreadLocal.get()
取得的还是这个共享对象本身,还是有并发访问问题。
InheritableThreadLocal
InheritableThreadLocal
解决父子线程变量传递的问题。
- 如果我在后面改了父线程,子线程不会更新它的本地变量
Map
。这个
ThreadLocalMap
的局部变量,实际作用是在子线程创建的时候:
- 父线程会把
threadLocal
拷贝到子线程中。
1 | public class InheritableThreadLocalSolution { |
InheritableThreadLocal的内存泄漏
当移除父线程的
threadlocal
,子线程的threadlocal
并不会消失。
- 并且通常来讲子线程是放线程池管理的,不会随着父线程的消失而消失。
所以子线程的
threadlocal
就一直存在,但是已经用不到了,这就造成了内存泄漏了。
1 | public class InheritableThreadLocalSolution { |
TransmittableThreadLocal
项目地址:https://gitee.com/mirrors/transmittable-thread-local
对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的。
- 这时父子线程关系的
ThreadLocal
值传递已经没有意义
- 需要把 任务提交给线程池时的
ThreadLocal
值传递到 任务执行时。
TransmittableThreadLocal
解决线程池变量丢失问题。
- 线程池会复用之前的线程,导致父线程的本地变量更新之后,之前创建的子线程拿不到这个值。
get/set
方法中完成了TransmittableThreadLocal
的注册
- 然后在执行run方法的时候通过
TtlRunnable
进行了方法包装在调用之前进行快照形成,并应用快照到当前线程中
最后在线程执行结束之后,run方法内部对线程局部变量做的修改则会被还原。
通过将线程封装成
TtlRunnable
,然后通过快照还有hold
一个总收集变量来解决。
集合类
HashMap
HashMap 底层的数据结构主要是:数组 + 链表 + 红黑树。
其中当链表的长度大于等于 8 时,链表会转化成红黑树,当红黑树的大小小于等于 6 时,红黑树会转化成链表。
常见属性
1 | //初始容量为 16 |
新增
1、空数组有无初始化,没有的话初始化。
2、如果通过 key 的 hash 能够直接找到值,跳转到 6,否则到 3。
3、如果 hash 冲突,两种解决方案:链表 or 红黑树。
4、如果是链表,递归循环,把新元素追加到队尾。
5、如果是红黑树,调用红黑树新增的方法。
6、通过 2、4、5 将新元素追加成功,再根据 onlyIfAbsent 判断是否需要覆盖。
7、判断是否需要扩容,需要扩容进行扩容,结束。
1 | // 入参 hash:通过 hash 算法计算出来的值。 |
链表的新增
当链表长度大于等于 8 时,此时的链表就会转化成红黑树,转化的方法是:treeifyBin,此方法有一个判断,当链表长度大于等于 8,并且整个数组大小大于 64 时,才会转成红黑树,当数组大小小于 64 时,只会触发扩容,不会转化成红黑树。
JDK1.7中链表插入采用的是头插法,JDK1.8中插入使用的是尾插法。
- 头插法:扩容时,扩容的逻辑会导致节点互相引用,导致死循环。
为什么是8?
链表查询的时间复杂度是
O(n)
,红黑树的查询复杂度是O(log(n))
。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗。
在考虑设计 8 这个值的时候,参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:
1 | * 0: 0.60653066 |
当链表的长度是 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 节点,有的话判断是链表类型,还是红黑树类型。
分别走链表和红黑树不同类型的查找方法。
1 | // 采用自旋方式从链表中查找 key,e 初始为为链表的头节点 |
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 时扩容的值。
新增
判断是否需要扩容,如果需要则执行扩容操作。
赋值新元素。
1 | public boolean add(E e) { |
1 | private void ensureCapacityInternal(int minCapacity) { |
扩容的规则并不是翻倍,而是 原来容量大小 + 原来容量大小的一半(原来容量的 1.5 倍)。
ArrayList 中数组的最大容量是
Integer.MAX_VALUE
,超过这个值,JVM 就不会给数组分配内存空间了。新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的。
扩容
扩容会先新建一个符合我们预期容量
newCapacity
的新数组,然后把旧数组的数据拷贝过去。
1 | public static int[] copyOf(int[] original, int newLength) { |
线程安全
只有当
ArrayList
作为共享变量时,才会有线程安全问题,当ArrayList
是方法内部局部变量时,是没有线程安全问题的。本质是因为 ArrayList 自身的 elementData、size、modCount 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见的(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况。
ArrayList和LinkedList使用不当,性能差距会如此之大
LinkedList
LinkedList 适用于要求有顺序、并且会按照顺序进行迭代的场景,底层数据结构是一个双向链表:
链表每个节点我们叫做 Node,Node 有 prev 属性,代表前一个节点的位置,next 属性,代表后一个节点的位置。
first 是双向链表的头节点,它的前一个节点是 null。
last 是双向链表的尾节点,它的后一个节点是 null。
当链表中没有数据时,first 和 last 是同一个节点,前后指向都是 null。
因为是个双向链表,只要机器内存足够强大,是没有大小限制的。
1 | transient Node<E> first;//第一个节点 |
1 | private static class Node<E> { |
追加(新增)
追加节点时,add 方法默认是从尾部开始追加,addFirst 方法是从头部开始追加。
尾部追加节点比较简单,只需要简单地把指向位置修改下即可。
1 | // 从尾部开始追加节点 |
节点删除
节点删除的方式和追加类似,可以选择从头部删除,也可以选择从尾部删除,删除操作会把节点的值,前后指向节点都置为 null,帮助 GC 进行回收。
链表结构的节点新增、删除都非常简单,仅仅把前后节点的指向修改下就好了,所以 LinkedList 新增和删除速度很快。
1 | public E removeFirst() { |
节点查询
链表查询某一个节点是比较慢的,需要挨个循环查找。
LinkedList 并没有采用从头循环到尾的做法,而是采取了简单二分法,首先看看 index 是在链表的前半部分,还是后半部分。
如果是前半部分,就从头开始寻找,反之亦然。通过这种方式,使循环的次数至少降低了一半,提高了查找的性能。
1 | // 根据链表索引位置查询节点 |
HashSet
HashSet原理:使用的就是组合 HashMap。
- 把 HashMap 当作自己的一个局部变量。
1 | // 把 HashMap 组合进来,key 是 Hashset 的 key,value 是下面的 PRESENT |
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 常见的属性有:
1 | //比较器,如果外部有传进来 Comparator 比较器,首先用外部的 |
新增节点
判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点:
1 | Entry<K,V> t = root; |
根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点:
1 | Comparator<? super K> cpr = comparator; |
在父节点的左边或右边插入新增节点:
1 | //cmp 代表最后一次对比的大小,小于 0 ,代表 e 在上一节点的左边 |
1、着色旋转,达到平衡,结束。
2、新增节点时,就是利用了红黑树左小右大的特性,从根节点不断往下查找,直到找到节点是 null 为止,节点为 null 说明到达了叶子结点。
3、查找过程中,发现 key 值已经存在,直接覆盖。
4、TreeMap 是禁止 key 是 null 值的。
LinkedHashMap
LinkedHashMap 是继承 HashMap 的,所以它拥有 HashMap 的所有特性,再此基础上,还提供了两大特性:
- 按照插入顺序进行访问。
- 实现了访问最少最先删除功能,其目的是把很久都没有访问的 key 自动删除。
LinkedHashMap链表结构
1 | // 链表头 |
LinkedHashMap 的数据结构很像是把 LinkedList 的每个元素换成了 HashMap 的 Node,像是两者的结合体,也正是因为增加了这些结构,从而能把 Map 的元素都串联起来,形成一个链表,而链表就可以保证顺序了,就可以维护元素插入进来的顺序。
如何按照顺序新增
LinkedHashMap 初始化时,默认 accessOrder 为 false,就是会按照插入顺序提供访问,插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用的 newNode/newTreeNode 和 afterNodeAccess 方法。
newNode/newTreeNode 方法,控制新增节点追加到链表的尾部,这样每次新节点都追加到尾部,即可保证插入顺序了。
1 | // 新增节点,并追加到链表的尾部 |
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
1 | public interface SPIService { |
定义两个实现类
1 | public class SpiImpl1 implements SPIService{ |
在ClassPath路径下配置添加一个文件。
文件名字是接口的全限定类名,内容是实现类的全限定类名,多个实现类用换行符分隔。
1 | com.viewscenes.netsupervisor.spi.SpiImpl1 |
通过
ServiceLoader.load或者Service.providers
方法拿到实现类的实例。其中,
Service.providers
包位于sun.misc.Service
,而ServiceLoader.load
包位于java.util.ServiceLoader
。
1 | public class Test { |
基本原理:通过反射的方式,创建实现类的实例并返回。
新特性
JDK 17
Sealed类:
Sealed类是一种新的类修饰符,用于限制类的继承
- 可以控制哪些类可以继承自它,这样可以使得代码更加安全、可维护
Pattern Matching for Switch:
新的Switch语法,可以用于模式匹配
- 可以根据不同的模式执行不同的操作,从而使得代码更加简洁、易读、易维护
可以减少代码量,避免出现大量的
if-else
语句
改进的垃圾回收器(ZGC(新型垃圾收集器)):
改进了垃圾回收器,提高了垃圾回收的效率和吞吐量
- 可以更加高效地回收内存,从而提高应用程序的性能和响应速度
风格的内存管理:
引入了C++风格的内存管理,包括对堆内存分配的优化和对垃圾回收的改进
- C++风格的内存管理可以使得Java应用程序更加高效,从而提高应用程序的性能和响应速度
JDK21
序列化集合接口:
新增序列集合接口
SequencedCollection
- 常用的
ArrayList
、LinkedList
等都实现了这个接口
ZGC增加分代:
增加了分代功能,比如
CMS
收集器区分老年代和年轻代
- 这样一来,可以更频繁的回收年轻代
虚拟线程(协程):
虚拟线程可以看作是一种用户级线程,与操作系统的线程或进程不同
- 它是由编程语言或库提供的,而不是由操作系统管理的