本文最后更新于:2020年9月13日 晚上

以问答的形式梳理 Java 基础中一些重要的知识点。

简要介绍 JVM

JVM 是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现,目的是使用相同的字节码,得出相同的结果。

JVM 可以理解的代码就叫做字节码(.class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又有解释型语言可移植的特点。字节码不针对特定的机器,所以 Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

Java 程序从源代码到运行一般有下面 3 步:

需要格外注意 .class -> 机器码这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(热点代码),所以后面引进了 JIT 编译器,JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,会将字节码对应的机器码保存下来,下次可以直接使用,机器码的运行效率肯定是高于 Java 解释器的。所以说 Java 是编译与解释共存的语言。

HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),这也是 JIT 需要编译的部分。JVM 会根据代码每次被执行的情况收集信息,做一些相应的优化,因此执行的次数越多,速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),直接将字节码编译成机器码,避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。但是,AOT 编译器的编译质量比不上 JIT 编译器。

JRE 和 JDK

JDK 是 Java Development Kit,是功能齐全的 Java SDK。它包含JRE,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 JVM、Java 类库、Java 命令和其他一些基础构件。但是,它不能用于创建新程序。

如果只是为了运行 Java 程序的话,那么只安装 JRE 就可以了。如果需要进行 Java 编程方面的工作,就需要安装 JDK。但是,这不是绝对的。有时,即使不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,只是在应用程序服务器中运行 Java 程序。但是,因为应用程序服务器会将 JSP 转换为 Java servlet,需要使用 JDK 来编译 servlet,所以也要安装 JDK。

Java 编译与解释并存

高级编程语言按照程序的执行方式分为编译型和解释型两种。简单来说,编译型语言是指编译器针对特定的操作系统将源代码一次性翻译成可被该平台执行的机器码;解释型语言是指解释器对源程序逐行解释成特定平台的机器码并立即执行。

Java 语言具有编译型语言和解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码,这种字节码由 Java 解释器来解释执行。

字符型常量和字符串常量

形式上:字符常量是单引号引起的一个字符; 字符串常量是双引号引起的若干个字符。

含义上:字符常量相当于一个整型值(ASCII 值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置)。

占内存大小:字符常量只占 2 个字节;字符串常量占若干个字节(char 在 Java 中占两个字节)。

Java泛型,类型擦除及常用通配符

Java 泛型(Generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。

Java的泛型是伪泛型,这是因为Java在编译期间,所有的泛型信息都会被擦掉,这也就是通常所说类型擦除 。

深拷贝与浅拷贝

  • 浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝。

  • 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容。

Object 类中的方法

public native int hashCode();

public boolean equals(Object obj) {
    return (this == obj);
}

protected native Object clone() throws CloneNotSupportedException;

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

public final native void notify();

public final native void notifyAll();

public final native void wait(long timeout) throws InterruptedException;

public final void wait(long timeout, int nanos) throws InterruptedException {
    if (timeout < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (nanos < 0 || nanos > 999999) {
        throw new IllegalArgumentException(
            "nanosecond timeout value out of range");
    }

    if (nanos > 0) {
        timeout++;
    }

    wait(timeout);
}

public final void wait() throws InterruptedException {
    wait(0);
}

protected void finalize() throws Throwable { }

重写与重载

  • 重载(overload):同一个方法根据输入数据的不同,做出不同的处理;

    • 发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同;
    • 同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理;
  • 重写(override):子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,要覆盖父类方法;

    • 重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写;
    • 返回值类型、方法名、参数列表必须相同,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类;
    • 如果父类方法访问修饰符为 private/final/static,则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明;
    • 构造方法无法被重写;
    • 重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变;
区别点 重载方法 重写方法
发生范围 同一个类 子类中
参数列表 必须修改 不能修改
返回类型 可修改 不能修改
异常 可修改 可以减少或删除,不能抛出新的或者更广的异常
访问修饰符 可修改 不能做更严格的限制(可以降低限制)
发生阶段 编译期 运行期

构造器 Constructor

Constructor 不能被重写,但是可以重载,所以一个类中可以有多个构造函数。

定义不使用且无参的构造方法的作用

  • Java 程序在执行子类的构造方法之前,如果没用 super() 来调用父类特定的构造方法,则会调用父类中没有参数的构造方法;
  • 因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 super() 来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到无参的构造方法供执行。解决办法是在父类里加上一个不做事且无参的构造方法。

成员变量与局部变量

  • 从语法形式上看:

    • 成员变量是属于类的,而局部变量是在方法中定义的变量或是方法的参数;
    • 成员变量可以被 public,private,static 等修饰符修饰,而局部变量不能被访问控制修饰符及 static 修饰;
    • 成员变量和局部变量都能被 final 修饰;
  • 从变量在内存中的存储方式来看:

    • 如果成员变量使用 static 修饰,那么这个成员变量是属于类的,如果没有用 static 修饰,这个成员变量属于实例;
    • 对象存在于堆内存,局部变量则存在于栈内存;
  • 从变量在内存中的生存时间上看:

    • 成员变量是对象的一部分,它随着对象的创建而存在;
    • 局部变量随着方法的调用而自动消失;
  • 成员变量如果没有被赋初值会自动以类型的默认值而赋值(被 final 修饰的成员变量必须显式地赋值),而局部变量则不会自动赋值;

对象实例与对象引用

  • new 创建对象实例,对象引用指向对象实例;

    • 对象实例在堆内存中,对象引用存放在栈内存中;
  • 一个对象引用可以指向 0 个或 1 个对象;一个对象可以有 n 个引用指向它;

    • 一根绳子可以不系气球,也可以系一个气球;可以用 n 条绳子系住一个气球;

构造方法的作用

类的构造方法的主要作用是完成对类对象的初始化工作。

若一个类没有声明构造方法,该程序也可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了,这时候,就不能直接 new 一个对象而不传递参数了,所以很多时候在不知不觉地使用构造方法,这也是为什么在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果重载了有参的构造方法,可以把无参的构造方法也写出来(无论是否用到),避免在创建对象的时候踩坑。

调用子类构造方法之前先调用父类没有参数的构造方法可以帮助子类进行初始化工作。

构造方法的特性

  • 名字与类名相同;

  • 没有返回值,但不能用 void 声明构造函数;

  • 生成类的对象时自动执行,无需调用;

对象的相等与指向他们的引用相等

对象的相等,比的是内存中存放的内容是否相等。而引用相等,比较的是他们指向的内存地址是否相等。

“==”相等判断符用于比较基本数据类型和引用类型数据。当比较基本数据类型的时候比较的是数值,当比较引用类型数据时比较的是引用(指针)。

引用数据类型指向堆内存中一个具体的对象。比如 Student stu = new Student(); stu 就是一个引用,它指向的是 new 出来的 Student 对象。当需要操作这个 Student 对象的时候,只需要操作引用即可,比如 int age = stu.getAge();。

“==”判断两个引用数据类型是否相等的时候,实际上是在判断两个引用是否指向同一个对象。

equals() 和 == 的本质区别的更通俗的说法是:==的比较规则是定死的,就是比较两个数据的值。而 equals() 的比较规则是不固定的,可以由用户自己定义。

如果想执行对象相等性的比较,就要覆盖 hashCode() 和 equals() 方法。

例如 HashSet 类会采用如下方法比较加入的对象是否与已经存在的对象相等:首先调用 hashCode() 比较要加入的对象与已经存在的对象的 hashcode 值,如果不存在相等的hashcode值,则不存在与要加入对象相等的对象,可以加入该对象;如果存在相等的hashcode值,则调用 equals() 检查 hashcode 相等的对象是否真的相等,如果两者相等,则要加入的对象已经存在了,加入操作就不会发生。

注意如果两个对象equal,则它们的hashcode值必须相同(所以如果覆盖了 equals() 方法,也必须同时覆盖 hashCode() 方法),但是如果两个对象具有相同的hashcode 值,它们不一定equal,所以 hashcode 值相同的对象还需要调用 equals() 来判断是否真的相等。

String类已经覆盖了 hashCode() 和 equals() 方法,所以 String 类的 hashCode() 和 equals() 方法执行的是对象相等性的比较。

Java 中 IO 流

  • 按照流的流向分,可分为输入流和输出流;
  • 按照操作单元划分,可划分为字节流和字符流;
  • 按照流的角色划分为节点流和处理流;

Java IO 流涉及 40 多个类,这些类看上去杂乱,实际很有规则,彼此之间紧密联系, Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生的:

  • InputStream/Reader:所有的输入流的基类,前者是字节输入流,后者是字符输入流;
  • OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流;

字节流与字符流

问题本质想问:对应文件读写、网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

字符流是由 Java 虚拟机将字节转换得到的,过程比较耗时,并且,如果不知道编码类型容易出现乱码问题。所以,I/O 流提供了一个直接操作字符的接口,方便平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

序列化

序列化是指把一个Java对象变成二进制内容,本质上就是一个 byte[] 数组。将 Java 对象进行序列化的原因是,序列化后可以把 byte[] 保存到文件中,或者把 byte[] 通过网络传输到远程,这样,就相当于把 Java 对象存储到文件或者通过网络传输出去了。反序列化指的是把一个二进制内容(byte[] 数组)变回 Java 对象。反序列化可以将保存在文件中的 byte[] 数组转换为 Java 对象,或者从网络上读取 byte[] 并把它转换为 Java 对象。

一个 Java 对象要能序列化,必须实现一个特殊的 java.io.Serializable 接口,它的定义如下。Serializable 接口没有定义任何方法,它是一个空接口。这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。

public interface Serializable {
}

因为 Java 的序列化机制可以导致一个实例能直接从 byte[] 数组创建,而不经过构造方法,因此,它存在一定的安全隐患。一个精心构造的 byte[] 数组被反序列化后可以执行特定的Java代码,从而导致严重的安全漏洞。

实际上,Java 本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过 JSON 这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。

BIO、NIO 和 AIO 的区别

  • BIO (Blocking I/O):同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型很好,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,对于十万甚至百万级的连接,传统的 BIO 模型是无能为力的。因此需要更高效的 I/O 处理模型来应对更高的并发量;
  • NIO (Non-blocking/New I/O):NIO 是一种同步非阻塞的 I/O 模型,Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现。两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发;
  • AIO (Asynchronous I/O):AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。Netty 之前也尝试使用过 AIO,不过又放弃了。

异常处理

对比 Exception 和 Error,比较运行时异常和一般异常的区别。

Exception 和 Error 都是继承了 Throwable 类,在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Exception 和 Error 体现了 Java 平台设计者对不同异常情况的分类。Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

Error 是指在正常情况下,不大可能出现的情况,绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。

Exception 又分为检查(checked)异常和非检查(unchecked)异常,检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。不可检查的 Error,是 Throwable 不是 Exception。

注:JAVA语言规范将派生于Error类或RuntimeException类的所有异常称为非检查(unchecked)异常,所有其他的异常称为检查(checked)异常。”

非检查异常包含类似 NullPointerException、ArrayIndexOutOfBoundsException 之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。

常量池

Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte、Short、Integer、Long、Character、Boolean。前 4 种包装类默认创建了数值[-128,127] 的相应类型的缓存数据,Character创建数值在[0,127]范围的缓存数据,Boolean 直接返回True Or False。如果超出对应范围仍然会去创建新的对象。把经常用到的数据存放在某块内存中,可以避免频繁的数据创建与销毁,实现数据共享,提高系统性能。

字符串常量池是Java常量池技术的一种实现, 在近代的 JDK 版本中(1.7后), 字符串常量池被实现在Java堆内存中。

当用new关键字创建字符串对象时, 不会查询字符串常量池;当用双引号直接声明字符串对象时, 虚拟机将会查询字符串常量池。也就是说,字符串常量池提供了字符串的复用功能, 除非要显式创建新的字符串对象,否则对同一个字符串虚拟机只会维护一份拷贝。

单例模式

意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决:一个全局使用的类频繁地创建与销毁。

何时使用:想控制实例数目,节省系统资源的时候。

如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

关键代码:构造函数是私有的。

应用实例:

  • 一个班级只有一个班主任;
  • Windows 是多进程多线程的,在操作一个文件的时候,就不可避免地出现多个进程或线程同时操作一个文件的现象,所以所有文件的处理必须通过唯一的实例来进行;
  • 一些设备管理器常设计为单例模式,如一个电脑有两台打印机,在输出的时候要处理不能两台打印机打印同一个文件;

优点:

  • 在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如网站首页页面缓存);
  • 避免对资源的多重占用(比如写文件操作)。

缺点:没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

使用场景:

  • 要求生产唯一序列号;
  • WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来;
  • 创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等;

注意事项:

  • getInstance() 方法中需要使用同步锁 synchronized (Singleton.class) 防止多线程同时进入造成 instance 被多次实例化;

  • 饿汉式:类一旦加载,就把单例初始化完成,保证 getInstance 的时候,单例是已经存在的了;

    • 饿汉式天生是线程安全的,因为它只在最开始实例化一次
  • 懒汉式:只有调用 getInstance 的时候,才去初始化这个单例。

    • 懒汉式本身是非线程安全的。

双检锁/双重校验锁(DCL,即 double-checked locking)

Lazy 初始化,是多线程安全

采用双锁机制,安全且在多线程情况下能保持高性能。getInstance() 的性能对应用程序很关键。

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  
        }  
        }  
    }  
    return singleton;  
    }  
}

工厂模式

对照着这个参考资料1的代码示例参考资料2中的图 理解工厂模式。

在工厂模式中,创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象。

意图:定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行。

主要解决:接口选择的问题。

何时使用:明确地计划不同条件下创建不同实例时。

如何解决:让其子类实现工厂接口,返回的也是一个抽象的产品。

关键代码:创建过程在其子类执行。

应用实例:您需要一辆汽车,可以直接从工厂里面提货,而不用去管这辆汽车是怎么做出来的,以及这个汽车里面的具体实现。

优点:

  • 一个调用者想创建一个对象,只要知道其名称就可以了;
  • 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以;
  • 屏蔽产品的具体实现,调用者只关心产品的接口。

缺点:每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖。

使用场景:

  • 日志记录器:记录可能记录到本地硬盘、系统事件、远程服务器等,用户可以选择记录日志到什么地方;
  • 数据库访问,当用户不知道最后系统采用哪一类数据库,以及数据库可能有变化时;
  • 设计一个连接服务器的框架,需要三个协议,”POP3”、”IMAP”、”HTTP”,可以把这三个作为产品类,共同实现一个接口。

注意事项:作为一种创建类模式,在任何需要生成复杂对象的地方,都可以使用工厂方法模式。有一点需要注意的地方就是复杂对象适合使用工厂模式,而简单对象,特别是只需要通过 new 就可以完成创建的对象,无需使用工厂模式。如果使用工厂模式,就需要引入一个工厂类,会增加系统的复杂度。

代理模式

在代理模式(Proxy Pattern)中,一个类代表另一个类的功能。这种类型的设计模式属于结构型模式。代理模式创建具有现有对象的对象,以便向外界提供功能接口。

意图:为其他对象提供一种代理以控制对这个对象的访问。

主要解决:在直接访问对象时带来的问题,比如说:要访问的对象在远程的机器上。在面向对象系统中,有些对象由于某些原因(比如对象创建开销很大,或者某些操作需要安全控制,或者需要进程外的访问),直接访问会给使用者或者系统结构带来很多麻烦,可以在访问此对象时加上一个对此对象的访问层。

何时使用:想在访问一个类时做一些控制。

如何解决:增加中间层。

关键代码:实现与被代理类组合。

应用实例:

  • Windows 里面的快捷方式;
  • 买火车票不一定在火车站买,也可以去代售点;
  • 一张支票或银行存单是账户中资金的代理。支票在市场交易中用来代替现金,并提供对签发人账号上资金的控制;
  • Spring AOP。

优点:

  • 职责清晰;
  • 高扩展性;
  • 智能化。

缺点:

  • 由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢;
  • 实现代理模式需要额外的工作,有些代理模式的实现非常复杂。

使用场景:按职责来划分,通常有以下使用场景: 1程代理。 2、虚拟代理。 3、Copy-on-Write 代理。 4、保护(Protect or Access)代理。 5、Cache代理。 6、防火墙(Firewall)代理。 7、同步化(Synchronization)代理。 8、智能引用(Smart Reference)代理。

注意事项:

  • 和适配器模式的区别:适配器模式主要改变所考虑对象的接口,而代理模式不能改变所代理类的接口;
  • 和装饰器模式的区别:装饰器模式为了增强功能,而代理模式是为了加以控制。

动态代理利用了JDK API,动态地在内存中构建代理对象,从而实现对目标对象的代理功能。动态代理又被称为 JDK 代理或接口代理。

静态代理与动态代理的区别主要在:

  • 静态代理在编译时就已经实现,编译完成后代理类是一个实际的 class 文件;
  • 动态代理是在运行时动态生成的,即编译完成后没有实际的 class 文件,而是在运行时动态生成类字节码,并加载到 JVM 中。

特点:动态代理对象不需要实现接口,但是要求目标对象必须实现接口,否则不能使用动态代理。

final、finally、 finalize

  • final 可以用来修饰类、方法、变量,分别有不同的意义,final 修饰的 class 代表不可以继承扩展,final 的变量是不可以修改的,而 final 的方法也是不可以重写的(override);
  • finally 则是 Java 保证重点代码一定要被执行的一种机制。可以使用 try-finally 或者 try-catch-finally 来进行类似关闭 JDBC 连接、保证 unlock 锁等动作;
  • finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize 机制现在已经不推荐使用,并且在 JDK 9 开始被标记为 deprecated;

有 return 的情况下 try-catch-finally 的执行顺序:

  • 不管有没有出现异常,finally 块中代码都会执行;
  • 当 try 和 catch 中有 return 时,finally 仍然会执行;
  • finally 是在 return 后面的表达式运算后执行的(此时并没有返回运算后的值,而是先把要返回的值保存起来,不管 finally 中的代码怎么样,返回的值都不会改变,仍然是之前保存的值),所以函数返回值是在 finally 执行前确定的;
  • finally 中最好不要包含 return,否则程序会提前退出,返回值不是 try 或 catch 中保存的返回值。

强引用、软引用、弱引用、幻象引用

在 Java 语言中,除了原始数据类型的变量,其他所有都是所谓的引用类型,指向各种不同的对象,理解引用对于掌握 Java 对象生命周期和 JVM 内部相关机制非常有帮助。不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响。

  • 强引用(Strong Reference):最常见的普通对象引用,只要还有强引用指向对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略;
    • 强引用特点:平常典型编码Object obj = new Object() 中的 obj 就是强引用。通过关键字new创建的对象所关联的引用就是强引用。 当 JVM 内存空间不足,JVM宁愿抛出 OutOfMemoryError 运行时错误,使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题;
  • 软引用(SoftReference):一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OOM 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存;
  • 弱引用(WeakReference):不能使对象豁免垃圾收集,仅仅提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择;
    • 弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中;
    • 应用场景:用于内存敏感的缓存;
  • 幻象引用:有时候也翻译成虚引用,不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机制,也有人利用幻象引用监控对象的创建和销毁;
    • 应用场景:用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

String、StringBuffer 和 StringBuilder

  • String 是 Java 语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的 Immutable 类,被声明成为 final class,所有属性也都是 final 的。也由于它的不可变性,类似拼接、裁剪字符串等动作,都会产生新的 String 对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响;

  • StringBuffer 是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer 本质是一个线程安全的可修改字符序列,它保证了线程安全,也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是 StringBuilder;

  • StringBuilder 是 Java 1.5 中新增的,能力上和 StringBuffer 没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选;

Java 反射与动态代理的原理

编程语言通常有各种不同的分类角度,动态类型和静态类型就是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译期检查。

与其近似的还有一个对比,就是所谓强类型和弱类型,就是不同类型变量赋值时,是否需要显式地(强制)进行类型转换。那么,如何分类 Java 语言呢?通常认为,Java 是静态的强类型语言,但是因为提供了类似反射等机制,也具备了部分动态类型语言的能力。

反射机制是 Java 语言提供的一种基础功能,赋予程序在运行时自省(introspect,官方用语)的能力。通过反射可以直接操作类或者对象,比如获取某个对象的类定义,获取类声明的属性和方法,调用方法或者构造对象,甚至可以运行时修改类定义。

反射的主要使用场景:

需要用反射的时候自然会用。反射肯定是能不用就不用。一般有两种需要用反射的情况:

1)方法名,甚至类名,是从某些配置文件读出来的,这时候就需要用。更甚有些类直接就是动态生成的,比如 gclib,肯定要用反射;

2)框架,比如Spring,Junit,大量需要用到反射做各种骚操作;

3)不推荐在生产使用:需要访问 private 的属性和方法。

使用反射可以读private 了,那是不是有些矛盾了。private 因为不想让一些代码暴露在外面所以才使用 private,用反射可以读,是不是不太合理。就像卖保险柜的把保险柜的破解方法也告诉了别人一样。

访问private要设置一下 accessible 为 true。还是有限制的。private确实不应该被访问,但是总有特殊情况,就好像车不应该闯红灯,但是总有特殊情况,如果在红灯的时候直接在路口升起一堵墙,消防车救护车也不让过,也并非是好事。当一个人把属性设置为 private 的时候,也很难考虑到所有可能的情况。

动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装 RPC 调用、面向切面的编程(AOP)。

实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似 ASM、cglib(基于 ASM)、Javassist 等。

Class.forName, 对象.getClass, 类名.class

Class.forName 就是加载一个类,但是这个类只有在用到的时候才会被初始化,初始化可以认为就是执行 static 的初始化代码,以及检查这个类用到的别的类是否存在之类的。

对象 .getClass 和类名 .class 没什么太大区别。它们返回的类肯定都是已经初始化好的。如果说一定要有点区别的话,那也是最表面的区别。getClass 方法是调用对象的方法,那么首先必须得有一个对象才行。.class 是直接获取某个类的 class,不需要通过对象。

如果非要再说有啥区别,就跟 classloader 里各种弯弯绕有关系了,对象 .getClass 和类名 .class 得到的 class instance 可能会不一样,但是这些弯弯绕意义不大。

int 与 Integer

答:int 就是常说的整形数字,是 Java 的 8 个原始数据类型(Primitive Types,boolean、byte 、short、char、int、float、double、long)之一。Java 语言虽然号称一切都是对象,但原始数据类型是例外。

Integer 是 int 对应的包装类,它有一个 int 类型的字段存储数据,并且提供了基本操作,比如数学运算、int 和字符串之间转换等。在 Java 5 中,引入了自动装箱和自动拆箱功能(boxing/unboxing),Java 可以根据上下文,自动进行转换,极大地简化了相关编程。

关于 Integer 的值缓存,这涉及 Java 5 中另一个改进。构建 Integer 对象的传统方式是直接调用构造器,直接 new 一个对象。但是根据实践,发现大部分数据操作都是集中在有限的、较小的数值范围,因而,在 Java 5 中新增了静态工厂方法 valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。按照 Javadoc,这个值默认缓存是 -128 到 127 之间。

Java IO 与多路复用

Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。

  • 首先,传统的 java.io 包,它基于流模型实现,提供了一些最熟知的 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

    java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。

    很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。

    • 同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善;
    • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
  • 第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。

    • 同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理;
    • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
  • 第三,Java 7 中 NIO 有了进一步改进,即 NIO 2。引入了异步非阻塞 IO方式,也有人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

    • 异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理;
    • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

Java 文件拷贝

Java 有多种比较典型的文件拷贝实现方式,比如:

  • 利用 java.io 类库,直接为源文件构建一个 FileInputStream 读取,然后再为目标文件构建一个 FileOutputStream,完成写入工作;

    public static void copyFileByStream(File source, File dest) throws
            IOException {
        try (InputStream is = new FileInputStream(source);
             OutputStream os = new FileOutputStream(dest);){
            byte[] buffer = new byte[1024];
            int length;
            while ((length = is.read(buffer)) > 0) {
                os.write(buffer, 0, length);
            }
        }
     }
  • 或者,利用 java.nio 类库提供的 transferTo 或 transferFrom 方法实现。

    public static void copyFileByChannel(File source, File dest) throws
            IOException {
        try (FileChannel sourceChannel = new FileInputStream(source)
                .getChannel();
             FileChannel targetChannel = new FileOutputStream(dest).getChannel
                     ();){
            for (long count = sourceChannel.size() ;count>0 ;) {
                long transferred = sourceChannel.transferTo(
                        sourceChannel.position(), count, targetChannel);            sourceChannel.position(sourceChannel.position() + transferred);
                count -= transferred;
            }
        }
     }

当然,Java 标准类库本身已经提供了几种 Files.copy 的实现。对于 Copy 的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From 的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。

类加载过程与双亲委派模型

Java 通过引入字节码和 JVM 机制,提供了强大的跨平台能力,理解 Java 的类加载机制是深入 Java 开发的必要条件。

一般来说,Java 的类加载过程分为三个主要步骤:加载、链接、初始化,具体行为在Java 虚拟机规范里有非常详细的定义。

  • 首先是加载阶段,它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。

    • 加载阶段是用户参与的阶段,可以自定义类加载器,去实现自己的类加载过程。
  • 第二阶段是链接,这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:

    • 验证:这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
    • 准备:创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令;
    • 解析:在这一步会将常量池中的符号引用替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析;
  • 最后是初始化阶段:真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑;

双亲委派模型,简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。

常量和静态变量

定义下面这样的类型,分别提供了普通静态变量、静态常量,常量。又考虑到原始类型和引用类型可能有区别。

public class CLPreparation {
  public static int a = 100;
  public static final int INT_CONSTANT = 1000;
  public static final Integer INTEGER_CONSTANT = Integer.valueOf(10000);
}
  • 普通原始类型静态变量和引用类型(即使是常量),是需要额外调用 putstatic 等 JVM 指令的,这些是在显式初始化阶段执行,而不是准备阶段调用;

  • 原始类型常量,则不需要这样的步骤。其实,类加载机制的范围实在太大,从开发和部署的不同角度,各选取了一个典型扩展问题供参考:

    • 如果要真正理解双亲委派模型,需要理解 Java 中类加载器的架构和职责,至少要懂具体有哪些内建的类加载器,这些是上面的回答里没有提到的;以及如何自定义类加载器;
    • 从应用角度,解决某些类加载问题,例如Java 程序启动较慢,想办法尽量减小 Java 类加载的开销;

通常类加载机制有三个基本特征:

  • 双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader 机制,用户可以在标准 API 框架上,提供自己的实现,JDK 也需要提供些默认的参考实现。 例如,Java 中 JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器;

  • 可见性。子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,就没有办法利用类加载器去实现容器的逻辑;

  • 单一性。由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见;

运行时动态生成 Java 类

可以从常见的 Java 类来源分析,通常的开发过程是,开发者编写 Java 代码,调用 javac 编译成 class 文件,然后通过类加载机制载入 JVM,就成为应用运行时可以使用的 Java 类了。

从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用 Java 程序生成一段源码,然后保存到文件等,下面就只需要解决编译问题了。

有一种笨办法,直接用 ProcessBuilder 之类启动 javac 进程,并指定上面生成的文件作为输入,进行编译。最后,再利用类加载器,在运行时加载即可。

前面的方法,本质上还是在当前程序进程之外编译的,那么还有没有不这么 low 的办法呢?

可以考虑使用 Java Compiler API,这是 JDK 提供的标准 API,里面提供了与 javac 对等的编译器功能,具体请参考java.compiler相关文档。

进一步思考,一直围绕 Java 源码编译成为 JVM 可以理解的字节码,换句话说,只要是符合 JVM 规范的字节码,不管它是如何生成的,是不是都可以被 JVM 加载呢?能不能直接生成相应的字节码,然后交给类加载器去加载呢?

当然也可以,不过直接去写字节码难度太大,通常可以利用 Java 字节码操纵工具和类库来实现,比如 ASM、Javassist、cglib 等。

select 与 epoll

这一块是IO多路复用下的知识。

I/O多路复用

IO 复用模型在阻塞 IO 模型上多了一个 select 函数,select 函数有一个参数是文件描述符集合,意思就是对这些的文件描述符进行循环监听,当某个文件描述符就绪的时候,就对这个文件描述符进行处理。

这种 IO 模型是属于阻塞的 IO。 但是由于它可以对多个文件描述符进行阻塞监听,所以它的效率比阻塞 IO 模型高效。

IO 多路复用就是我们说的 select,poll,epoll。 select/epoll 的好处就在于单个 process就可以同时处理多个网络连接的 IO。 它的基本原理就是 select,poll,epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。 这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符) 其中的任意一个进入读就绪状态,select()函数就可以返回。

I/O 多路复用和阻塞 I/O 其实并没有太大的不同,事实上,还更差一些。 因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。 select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。 )

在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。 只不过 process 是被 select这个函数 block,而不是被 socket IO 给 block。

select

select:是最初解决IO阻塞问题的方法。用结构体 fd_set 来告诉内核监听多个文件描述符,该结构体被称为描述符集。 由数组来维持哪些描述符被置位了。 对结构体的操作封装在三个宏定义中。 通过轮寻来查找是否有描述符要被处理。

存在的问题:

  1. 内置数组的形式使得 select 的最大文件数受限与 FD_SIZE;
  2. 每次调用 select 前都要重新初始化描述符集,将 fd 从用户态拷贝到内核态,每次调用select 后,都需要将 fd 从内核态拷贝到用户态;
  3. 轮寻排查当文件描述符个数很多时,效率很低;

poll

poll:通过一个可变长度的数组解决了 select 文件描述符受限的问题。 数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。 poll 解决了 select 重复初始化的问题。 轮寻排查的问题未解决。

epoll

epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。 因此,epoll 采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。

epoll 对文件描述符的操作有两种模式:LT(level trigger) 和 ET(edge trigger) 。 LT模式是默认模式。

  1. LT 模式

    LT(level triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。 如果你不作任何操作,内核还是会继续通知你的。

  2. ET 模式

    ET(edge-triggered)是高速工作方式,只支持 no-block socket。 在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。 然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误) 。 但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

    ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。 epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

  3. LT 模式与 ET 模式的区别如下:

    LT 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。 下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。

    ET 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。 如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。

数据原子性

数据的读写能否保证数据原子性。

读多写少场景下如何设计可以性能提升

copy-on-write原则

CopyOnWrite 的原理是:任何修改操作,如 add、set、remove,都会拷贝原数组,修改后替换原来的数组,通过这种防御性的方式,实现另类的线程安全。适合读多写少的操作,不然修改的开销是比较高的。

这样做的好处在于,可以在并发的场景下对容器进行”读操作”而不需要”加锁”,从而达到读写分离的目的。从JDK 1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器 ,分别是CopyOnWriteArrayList和CopyOnWriteArraySet 。CopyOnWriteArraySet 是通过包装了 CopyOnWriteArrayList 来实现的。

参考资料

Head First Java - 第二版

JavaGuide

深入探究Java中equals()和==的区别是什么

详解Java中的字符串

JAVA BIO与NIO、AIO的区别

有 return 的情况下try catch finally的执行顺序


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

Java OOP一些理解 上一篇
Python Tips 下一篇