Java Basic Notes

2020-06-24  本文已影响0人  Lyudmilalala

JVM, JRE, and JDK

Java Virtual Machine是Java虚拟机,Java程序需要运行在虚拟机上,不同的平台有自己的虚拟机,因此Java语言可以实现跨平台。
Java Runtime Environment包括Java虚拟机和Java程序所需的核心类库等。如果想要运行一个开发好的Java程序,计算机中只需要安装JRE即可。
Java Development Kit是提供给Java开发人员使用的,其中包含了Java的开发工具,也包括了JRE。


访问修饰符的可见性

this和super

this指向当前对象,super指向离当前对象最近的父类。
从本质上讲,this是一个指向本对象的指针, 然而super是一个Java关键字。
他们被用来引用当前对象/父类的变量,方法,和构造器。当this/super构造器放在其他构造函数中时,需放在构造函数内第一行。this和super不能同时出现在一个构造函数里面。

static

被static修饰的变量或者方法是独立于该类的任何对象,也就是说,static变量和方法不属于任何一个实例对象,而是被类的实例对象所共享。
static修饰的变量或者方法只会在类第一次被加载的时候被加载并初始化一次
被static修饰的变量或者方法是优先于对象存在的,也就是说当一个类加载完毕之后,即便没有创建对象,也可以去访问。
static变量值在类加载的时候分配空间,以后创建类对象的时候不会重新分配。
在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。
静态只能访问静态,非静态既可以访问非静态的,也可以访问静态的。
静态变量不能被序列化。

Inner Class

内部类可以分为四种:成员内部类、局部内部类、匿名内部类和静态内部类

匿名内部类

匿名内部类必须继承一个抽象类或者实现一个接口。
匿名内部类不能定义任何静态成员和静态方法。
当所在的方法的形参需要被匿名内部类使用时,必须声明为 final。
匿名内部类不能是抽象的,它必须要实现继承的类或者实现的接口的所有抽象方法。

Why outer local variables called by local inner class and anonymous inner class must be final?

因为生命周期不一致, 局部变量直接存储在栈中,当方法执行结束后,非final的局部变量就被销毁。而局部内部类对局部变量的引用依然存在,如果局部内部类要调用局部变量时,就会出错。加了final,可以确保局部内部类使用的变量与外层的局部变量区分开,解决了这个问题。

public class Outer {

    void outMethod(){
        final int a =10;
        class Inner {
            void innerMethod(){
                System.out.println(a);
            }

        }
    }
}

Why we use Inner Class?

一个内部类对象可以访问创建它的外部类对象的内容,包括私有数据!
内部类不为同一包的其他类所见,具有很好的封装性;
内部类有效实现了“多重继承”,优化 java 单继承的缺陷。
匿名内部类可以很方便的定义回调。

Abstract class vs Interface

抽象类是用来捕捉子类的通用特性的。接口是抽象方法的集合。
从设计层面来说,抽象类是对类的抽象,是一种模板设计,接口是行为的抽象,是一种行为的规范。

== vs equals() vs compareTo()

==

If variables A and B are primary types, == compares their values.
如果变量A和B是基本数据类型,==比较它们的值。
If variables A and B are objects, == compares the address of objects.
如果变量A和B是引用类型(对象),==比较它们的地址。

equals()

Object class implements the equals() method as comparing the address of two Object classes.
Object类实现的equals()方法比较了两个Object的地址。
Indeed, if a child class has not override the equals() method, comparing two objects by equals() is the same as comparing by ==.
所以,如果其子类没有覆盖equals()方法。则通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象。
If a child class has not override the equals() method (for example String), then equals() returns true if the values of variables A and B are equal.
如果其子类覆盖了equals()方法 (如String类),则若变量A和B的内容相等,则返回 true。

hashCode()与equals()

如果两个对象相等,则hashcode一定也是相同的。两个对象有相同的hashcode值,它们也不一定是相等的。
equals 方法被覆盖过,则 hashCode 方法也必须被覆盖。

compareTo()

只有实现了Comparable接口里compare()方法的类才可以使用compareTo()方法。
重写的A.compare(B)方法返回int值:

Comparable vs Comparator

Comparable接口实际上是出自java.lang包,它有一个 compareTo(Object obj)方法用来排序。
当我们想创建比较一个对象的方法,用于稍后对集合中的对象排序时,实现Comparable接口。
放进有序集合(如TreeMap,TreeSet)或者可以使用Collections.sort()方法的集合对象必须要实现Comparable接口。
Comparator接口实际上是出自 java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序。
当我们想创建一个比较器来汇总多种用来比较一个对象的方法时,实现Comparator接口。
当一个集合list里的对象没有实现Comparable接口时,可以通过Collections.sort(list, new Comparator<? super T> c)注入临时创建的实现了Comparator接口的子类来进行比较。

String vs StringBuilder vs StringBuffer

String

StringBuilder

继承自AbstractStringBuilder类,定义了对String进行基本操作的方法,非线程安全

StringBuffer

继承自AbstractStringBuilder类,定义了对String进行基本操作的方法,并在方法上加了同步锁,线程安全

Throwable

有两个子类Error和Exception,它们通常用于指示发生了异常情况。

Error

Error 类型的错误通常为虚拟机相关错误,如系统崩溃,内存不足,堆栈溢出等,编译器不会对这类错误进行检测,一旦这类错误发生,通常应用程序会被终止,仅靠应用程序本身无法恢复。
包括NoClassDefFoundError,OutOfMemoryError,StackOverflowError。

Exception

Exception 类的错误是可以在应用程序中进行捕获并处理的,通常遇到这种错误,应对其进行处理,使应用程序可以继续正常运行。

Runtime Exception vs Compiled Eexception

Runtime Exception 运行时异常

运行时异常包括 RuntimeException 类及其子类,表示 JVM 在运行期间可能出现的异常。 Java 编译器不会检查运行时异常。我们在程序里可以选择写捕获语句,也可以选择不处理。就算没有写处理语句,RuntimeException异常也会由JVM自动抛出并自动捕获。
包括NullPointerException,ArrayIndexOutBoundException,ArithmeticExecption。

Compiled Eexception 编译异常

编译异常是Exception中除 RuntimeException及其子类之外的异常。 Java 编译器会检查此类异常,该异常我们必须手动在代码里添加捕获语句来处理该异常。
包括IOException,ClassNotFoundException。

Checked vs Non-Checked Exception

Checked Exception 受检异常

编译器要求必须处理的异常。可以用try-catch捕获或者throws抛出的异常。除RuntimeException及其子类外,其他的Exception异常都属于受检异常。

Non-Checked Exception 不受检异常

编译器不会进行检查并且不要求必须处理的异常,也就说当程序中出现此类异常时,即使我们没有try-catch捕获它,也没有使用throws抛出该异常,编译也会正常通过。该类异常包括运行时异常(RuntimeException极其子类)和错误(Error)。

How do Java handle Exceptions?

在一个方法中如果发生异常,这个方法会创建一个异常对象,并转交给 JVM,该异常对象包含异常名称,异常描述以及异常发生时应用程序的状态。创建异常对象并转交给 JVM 的过程称为抛出异常。可能有一系列的方法调用,最终才进入抛出异常的方法,这一系列方法调用的有序列表叫做调用栈。JVM 会顺着调用栈去查找看是否有可以处理异常的代码,如果有,则调用异常处理代码。当 JVM 发现可以处理异常的代码时,会把发生的异常传递给它。如果 JVM 没有找到可以处理该异常的代码块,JVM 就会将该异常转交给默认的异常处理器(默认处理器为 JVM 的一部分),默认异常处理器打印出异常信息并终止应用程序。
可以选择捕获异常(try...catch...),声明异常(throws)还是抛出异常(throw)。

throw vs throws

throw 关键字用在方法内部,只能用于抛出一种异常,用来抛出方法或代码块中的异常,受查异常和非受查异常都可以被抛出。
throws 关键字用在方法声明上,用来声明该方法可能抛出的异常列表。
一个方法用throws标识了可能抛出的异常列表,调用该方法的方法中必须包含可处理异常的代码,否则也要在方法签名中用 throws 关键字声明相应的异常。

final vs finally vs finalize

final可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修饰变量表示该变量是一个常量不能被重新赋值。
finally一般作用在try-catch代码块中,在处理异常的时候,通常我们将一定要执行的代码方法finally代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。
finalize是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一般由垃圾回收器来调用,当我们调用System.gc() 方法的时候,由垃圾回收器调用finalize(),回收垃圾,一个对象是否可回收的最后判断。

Collections

集合框架的三大块内容:

集合的特点

Iterator 迭代器

我们可以通过Iterator接口单向遍历任何Collection。它屏蔽了不同数据集合的特点,统一遍历集合的接口。
for(Object o : list)的内部实现也是迭代器。

ArrayList vs vector vs LinkedList

ArrayList

vector

LinkedList

HashMap vs HashSet vs ConcurrentHashMap

Hash

Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值)。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
不同的输入可能会散列成相同的输出,当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。

hash() vs hashcode()

hashCode()方法是为了优化Object的equals(),提高查找效率而创造的方法,返回的是int整数类型的对象地址,其范围为-2^{31}~2^{31}-1。每一个对象类都需要继承。
而HashMap的容量范围是在2^4~2^{30},由于HashMap数组大小和设备空间限制,hashCode()计算出的哈希值很可能不在HashMap数组大小范围内,进而无法匹配存储位置。所以HashMap中会有对hashCode进行二次加工的方法hash()。hash()方法中可以通过扰动充分利用hashCode()所获得的值,是数据分布更加平均。

HashMap

为什么HashMap的容量(非长度)总是2的幂次方?

传统的hash方法是对key的Unicode的hashCode取余,在二进制计算中,如果除数capacity,也就是容量,是2的幂次, hash%capacity==hash&(capacity-1)。使用二元运算符&比使用算数运算符%运算效率更高,故我们希望保证HashMap的容量总是2的幂次方。

如何解决Hash冲突

在哈希值计算上使用扰动。
传统的hash方法是对key的Unicode的hashCode取余,那么相当于参与运算的只有对象hashCode的低位,高位是没有起到任何作用的,所以我们希望hashCode的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动
通常的扰动是通过右移获取高位的值,在和原本的值进行异或运算

// JDK 1.8
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在数据结构上使用链表散列。
用一个数组储存所有的hash,每一个hash对应一个对象或一个对象链表。
当我们往Hashmap中put元素时,计算出储存当前key的hash在数组中的下标。

HashMap扩容

JDK1.8以前,扩容后的数据位置全部按照hash公式重新计算。
JDK1.8以后,扩容后的位置计算也因为平衡二叉树的性质得以改善(扩容后的位置=原位置 or 原位置 + 旧容量)。

什么样的Object可以作为HashMap的key
JDK1.8对HashMap的优化
JDK1.8以前 JDK1.8以后
存储结构 数组 + 链表 数组 + 链表 + 红黑树
初始化方式 单独函数inflateTable() 直接集成到了扩容函数resize()中
hash值计算方式 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算
存放数据的规则 无冲突时,存放数组;
冲突时,存放链表
无冲突时,存放数组;
冲突 & 链表长度 < 8:存放单链表;
冲突 & 链表长度 > 8:树化并存放红黑树
插入数据的方式 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) 尾插法(直接插入到链表尾部/红黑树)
扩容后存储位置的计算方式 全部重新去计算其Hash值,根据Hash值对其进行分发
hashCode ->> 扰动函数 ->> (h&length-1)
按照扩容后的规律计算
扩容后的位置=原位置 or 原位置 + 旧容量

HashSet

ConcurrentHashMap

ConcurrentHashMap vs HashTable

HashTable使用的是全表锁,不允许多个线程同时进行任何操作,可能导致许多线程饿死。
ConcurrentHashMap对hash键值数组进行了分段锁,相比起HashTable锁的粒度更精细,每一把锁只锁住一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

HashTable
ConcurrentHashMap (Before JDK 1.8)
ConcurrentHashMap (After JDK 1.8)

值传递vs引用传递

值传递:在方法调用的时候,实参是将自己的一份拷贝赋给形参,在方法内,对该参数值的修改不影响原来实参。
引用传递:在方法调用的时候,实参将自己的地址传递给形参,此时方法内的形参与方法外的实参指向同一处内存空间。方法内对该参数值的改变,就是对该实参的实际操作。
Java语言的方法调用只支持参数的值传递。
误区:值传递和引用传递,区分的条件是传递的内容,如果是个值,就是值传递。如果是个引用,就是引用传递。
Java中的基础数据类型将自己的值拷贝一份给方法,对象类型将自己引用的内存地址拷贝一份给方法。拷贝的内存地址也是内容信息的拷贝,而并非自己的地址本身,因此仍是值传递。
如下例子,swap()方法开始时,x和s1都指向小张的内存地址,y和s2都指向小李的内存地址。swap()交换了x和y的值,使x指向小李的内存地址,y指向小张的内存地址,但并未改变s1指向小张的内存地址,s2指向小李的内存地址的事实。

public class Test {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Student s1 = new Student("小张");
        Student s2 = new Student("小李");
        Test.swap(s1, s2);
        System.out.println("s1:" + s1.getName());
        System.out.println("s2:" + s2.getName());
    }

    public static void swap(Student x, Student y) {
        Student temp = x;
        x = y;
        y = temp;
        System.out.println("x:" + x.getName());
        System.out.println("y:" + y.getName());
    }

    // -------- Output --------
    // x:小李
    // y:小张
    // s1:小张
    // s2:小李
}

Object-oriented

相比起面向对象,有更好的复用性和扩展性,降低了系统耦合性,易于维护,但性能更差(因为对象实例化需要更多开销)。

三大特性

封装

隐藏对象的属性和实现细节,仅对外提供公共访问方式,将变化隔离,便于使用,提高复用性和安全性。
装箱:将基本类型用它们对应的引用类型包装起来。
拆箱:将包装类型转换为基本数据类型。

继承

使用已存在的类的定义作为基础建立新类的技术。
子类可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类
通过使用继承可以提高代码复用性。继承是多态的前提

多态

编译时的多态性(前绑定)

方法重载(overload)根据参数列表的不同来区分并编译出不同的函数,实现编译时的多态性。编译时的多态性是静态的

运行时的多态性(后绑定)

一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定。运行时的多态性是动态的,通过动态绑定来实现。
通过方法重写(override)和实现接口实现运行时的多态性。
Java多态的实现机制的原则:被引用对象的类型而不是引用变量的类型决定了调用谁的成员方法。因此当有父类A和子类BA a = B b;时,a.func();调用的事B类中的func()而不是A类中的。

五大原则

单一职责原则SRP(Single Responsibility Principle)

类的功能要单一,不能包罗万象,跟杂货铺似的。

开放封闭原则OCP(Open-Close Principle)

一个模块对于拓展是开放的,对于修改是封闭的,想要增加功能热烈欢迎,想要修改,哼,一万个不乐意。

里式替换原则LSP(the Liskov Substitution Principle LSP)

子类可以替换父类出现在父类能够出现的任何地方。

依赖倒置原则DIP(the Dependency Inversion Principle DIP)

高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。

接口分离原则ISP(the Interface Segregation Principle ISP)

设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。就比如一个手机拥有打电话,看视频,玩游戏等功能,把这几个功能拆分成不同的接口,比在一个接口里要好的多。

Serializable 可序列化

序列化是将内存中的对象转化为二进制数组得以在不同的介质(如磁盘,数据库)中储存或传输。
转化后的二进制数组中包含以下信息:序列化版本,完整类名,serialVersionUID,各个属性的类型、名字和值、父类信息。
将数据库,文件的中的内容完整的转换为对象的方式又称为反序列化
实现序列化的方法是实现空接口Serializable。如需自定义序列化方式,则可以实现Externalizable接口,重写writeObject()readObject()方法。
当一个对象被序列化时,只保存对象的非静态成员变量,不能保存任何的成员方法和静态的成员变量。
深复制: 如果一个对象的成员变量是一个对象,那么这个对象的数据成员也会被保存。
实现Serializable接口的对象类会产生serialVersionUID,在反序列化时保持了对象的唯一性,保证了版本的兼容性。
当使用Serializable接口时,如果一个可序列化的对象包含对某个不可序列化的对象的引用,则我们将不可序列化的变量标记为transient,否则序列化失败。一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。如果我们实现的是Externalizable接口而不是Serializable接口,则变量就算标记为transient也会被序列化。
transient只能用来修饰变量,而不能修饰方法和类。transient不能修饰局部变量,final变量和static变量。static变量不管是否被标记为transient,均不能被序列化。

为什么ArrayList中的elementData数组被transient修饰?

因为elementData是一个用来缓存元素的数组,它通常会预留一些容量,等容量不足时再扩充容量,有些空间可能就没有实际存储元素,序列化了也是浪费空间和时间。
ArrayList在序列化的时候直接将size和element写入ObjectOutputStream;反序列化时从ObjectInputStream获取size和element,再恢复到elementData,这样就可以保证只序列化实际存储的那些元素。

为什么LinkedList中的size,以及指向头结点和尾结点的指针被transient修饰?

因为当程序结束,LinkedList被序列化并移出内存时,头结点和尾结点的内存地址都已经改变,序列化保存下来也没有用。
LinkedList序列化的时候将链表按顺序拆分开来并按顺序写入ObjectOutputStream,仅序列化结点中保存的数据;反序列化时从ObjectInputStream依次获取数据并重新将它们的新内存地址链接起来。

IO

InputStream/OutputStream vs Reader/Writer vs BufferedInputStream/BufferedOuStream/BufferReader/BufferWriter

多路复用模型:Reactor vs Proactor

Reactor模式流程

同步IO

  1. 应用程序注册读就绪事件和相关联的事件处理器
  2. 事件分离器等待读就绪事件
  3. 当发生读就绪事件的时候,事件分离器调用第一步注册的事件处理器
  4. 事件处理器首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理
    写入操作类似于读取操作,只不过第一步注册的是写就绪事件。

Proactor模式流程

异步IO

  1. 应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件
  2. 事件分离器等待**读取操作完成事件 **
  3. 在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作(异步IO都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作,操作系统扮演了重要角色),并将读取的内容放入用户传递过来的缓存区中
  4. 事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。

BIO vs NIO vs AIO

Block IO (BIO) 同步阻塞式IO

最传统的IO,数据的读取写入必须阻塞在一个线程内等待其完成。
模式简单使用方便,并发处理能力低。

Non IO (NIO) 同步非阻塞IO

实现了多路复用。客户端和服务器端的线程将用来通讯的Channel(通道)注册到Selector(多路复用器)上,Selector轮询所有Channels的状态,当发现其中一个Channel已经就绪则进行后续操作。
此处同步的含义是,读写操作仍然在应用线程进行,只是将等待的时间剥离到单独的线程中去。
基于Reactor(反应器)
NIO的三个要素

Asynchronous IO (AIO) 异步非堵塞IO

也叫 NIO2,异步 IO 的操作基于windows上的IOCP和Linux系统上的Epoll机制,实现了订阅-通知模式和回调机制,无需一个线程去轮询所有IO操作的状态改变。检测到IO事件的应用程序向操作系统注册IO监听,然后直接返回,继续做自己的事情操作系统异步处理IO事件,并且准备好数据后,主动通知应用程序,触发相应的函数。
基于Proactor(前摄器)

Database Connection Pool

传统Java应用访问数据库的过程:

  1. 装载数据库驱动程序;
  2. 通过jdbc建立数据库连接;
  3. 访问数据库,执行sql语句;
  4. 断开数据库连接。

反复建立和断开数据库连接增加了时间和内存开销,还会增加因断开连接失败导致数据库内存泄漏的可能性。对此,我们可以采取存储一些数据库连接,并在不同线程需要时分配给他们重复利用的解决方法。

Reflection

What is reflection

JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。

When to use

  1. 我们在使用JDBC连接数据库时使用Class.forName()通过反射加载数据库的驱动程序;
  2. Spring框架通过xml配置模式装载 Bean 的过程:
    1. 将程序内所有 XML 或 Properties 配置文件加载入内存中;
    2. Java类里面解析xml或properties里面的内容,得到对应实体类的字节码字符串以及相关的属性信息;
    3. 使用反射机制,根据这个字符串获得某个类的Class实例;
    4. 动态配置实例的属性

Advantages & Disadvantages

Advantages: 运行期类型的判断,动态加载类,提高代码灵活度。
Disadvantages: 性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的java代码要慢很多。

Garbage Collection

定义垃圾的方法

引用计数法

在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。当该对象被其它对象引用时,它的引用计数加 1,当删除对该对象的引用时,它的引用计数减 1,当该对象的引用计数为 0 时,该对象会被回收。

可达性分析算法

将一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,走过的路径被称为 Reference Chain,当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。
可以作为GC Roots的对象包括:

回收方法

Mark-Sweep 标记-清除算法

把内存区域中可回收的对象进行标记,然后把这些垃圾拎出来清理掉。
容易操作,但会产生内存碎片。

Mark-Compact 标记-整理算法

把内存区域中存活的对象进行标记,让所有存活的对象都向一端移动,再清理端边界以外的内存区域。
解决标记清除算法的内存碎片问题,保证了内存的连续可用。但需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

Copying 复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
解决标记清除算法的内存碎片问题,保证了内存的连续可用。缺点是内存利用率不高,一次实际上只利用了一半。

References

Java基础知识面试题(2020最新版)
Java集合容器面试题(2020最新版)
java基础 | Serializable接口,transient关键字
ArrayList和LinkedList中的transient关键字和序列化
BIO、NIO、AIO 区别和应用场景
Java 编程思想(七) BIO/NIO/AIO的区别(Reactor和Proactor的区别)

上一篇下一篇

猜你喜欢

热点阅读