Java基础常见面试题(一)

1. 为什么说 Java 语言“编译与解释并存”?

我们可以将高级编程语言按照程序的执行方式分为两种:

  • 编译型 :编译型语言会通过编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。
  • 解释型 :解释型语言会通过解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。

java简答题考点 java简答题及答案_父类

这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的

程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。

Java 程序从源代码到运行的过程如下图所示:

java简答题考点 java简答题及答案_java简答题考点_02

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式

的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(just-in-time

compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使

用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言 。

2. 成员变量与局部变量的区别?

  • 语法形式 :从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
  • 存储空间 :从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
  • 生存时间 :从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
  • 默认值 :从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

3. 静态方法为什么不能调用非静态成员?

这个需要结合 JVM 的相关知识,主要原因如下:

  1. 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
  2. 在类的非静态成员不存在的时候静态成员就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。

4. 重载和重写有什么区别?

重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理

重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法

重载

发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

《Java 核心技术》这本书是这样介绍重载的:

如果多个方法(比如 StringBuilder 的构造方法)有相同的名字、不同的参数, 便产生了重载。

StringBuilder sb = new StringBuilder(); StringBuilder sb2 = new StringBuilder("HelloWorld");

编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。 如果编译器找不到匹配的参数, 就会产生编译时错误, 因为根本不存在匹配, 或者没有一个比其他的更好(这个过程被称为重载解析(overloading resolution))。

Java 允许重载任何方法, 而不只是构造器方法。

综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

重写

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  1. 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
  2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
  3. 构造方法无法被重写

综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。

区别点

重载方法

重写方法

发生范围

同一个类

子类

参数列表

必须修改

一定不能修改

返回类型

可修改

子类方法返回值类型应比父类方法返回值类型更小或相等

异常

可修改

子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;

访问修饰符

可修改

一定不能做更严格的限制(可以降低限制)

发生阶段

编译期

运行期

方法的重写要遵循“两同两小一大”(以下内容摘录自《疯狂 Java 讲义》 ):

  • “两同”即方法名相同、形参列表相同;
  • “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
  • “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。

⭐️ 关于 重写的返回值类型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。

public class Hero {
    public String name() {
        return "超级英雄";
    }
}

class SuperMan extends Hero {

    @Override
    public String name() {
        return "超人";
    }

    public Hero hero() {
        return new Hero();
    }
}

class SuperSuperMan extends SuperMan {
    @Override
    public String name() {
        return "超级超级英雄";
    }

    @Override
    public SuperMan hero() {
        return new SuperSuperMan();
    }
}

5. 基本类型和包装类型的区别?

  • 成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null。
  • 包装类型可用于泛型,而基本类型不可以。
  • 基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
  • 相比于对象类型, 基本数据类型占用的空间非常小。

为什么说是几乎所有对象实例呢? 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃

逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存

⚠️ 注意 : 基本数据类型存放在栈中是一个常见的误区! 基本数据类型的成员变量如果没有被 static 修饰的话(不建议这么使用,应该要使用基本数据类型对应的包装类型),就存放在堆中。

class BasicTypeVar{
  private int x;
}

6. 包装类型的缓存机制了解么?

Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范

围的缓存数据,Boolean 直接返回 True or False。

Integer 缓存源码:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
} 

private static class IntegerCache {
     static final int low = -128;
     static final int high;

     static {
         // high value may be configured by property
         int h = 127;
         // ...
     }
 }

Character 缓存源码:

public static Character valueOf(char c) {
    if (c <= 127) { // must cache
      return CharacterCache.cache[(int)c];
    }
    return new Character(c);
}

private static class CharacterCache {
    private CharacterCache(){}
    static final Character cache[] = new Character[127 + 1];
    static {
        for (int i = 0; i < cache.length; i++)
            cache[i] = new Character((char)i);
    }
}

Boolean 缓存源码:

public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}

如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。

两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。

Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true

Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false

Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false

下面我们来看一下问题。下面的代码的输出结果是 true 还是 false 呢?

Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);

Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓

存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。

故答案是false

记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。

自动装箱与拆箱

装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。

注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。

private static long sum() {
    // 应该使用 long 而不是 Long
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++)
        sum += i;
    return sum;
}

7. 对象实体与对象引用有何不同?

new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。

一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳

子系住一个气球)

对象的相等和引用相等的区别?

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

8. 构造方法有哪些特点?是否可被 override?

构造方法特点如下:

  • 名字与类名相同。
  • 没有返回值,但不能用 void 声明构造函数。
  • 生成类的对象时自动执行,无需调用。

构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。

9. 接口和抽象类有什么共同点和区别?

共同点 :

  • 都不能被实例化。
  • 都可以包含抽象方法。
  • 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。

区别 :

  • 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
  • 一个类只能继承一个类,但是可以实现多个接口。
  • 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。

10. 深拷贝和浅拷贝区别了解吗?什么是引用拷贝?

关于深拷贝和浅拷贝区别,我这里先给结论:

  • 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
  • 深拷贝 :深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

上面的结论没有完全理解的话也没关系,我们来看一个具体的案例!

浅拷贝

浅拷贝的示例代码如下,我们这里实现了 Cloneable 接口,并重写了 clone() 方法。

clone() 方法的实现很简单,直接调用的是父类 Object 的 clone() 方法。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Address implements Cloneable {
    private String name;

    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException(e);
        }
    }
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Person implements Cloneable {
    private Address address;

    @Override
    public Person clone() {
        try {
            Person person = (Person) super.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException(e);
        }
    }
}

测试

Person person = new Person(new Address("郑州"));
Person personCopy = person.clone();
// true
System.out.println(person.getAddress() == personCopy.getAddress());

从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。

深拷贝

这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。

@Override
public Person clone() {
    try {
        Person person = (Person) super.clone();
        person.setAddress(person.getAddress().clone());
        return person;
    } catch (CloneNotSupportedException e) {
        throw new RuntimeException(e);
    }
}

测试 :

Person person = new Person(new Address("郑州"));
Person personCopy = person.clone();
// false
System.out.println(person.getAddress() == personCopy.getAddress());

从输出结构就可以看出,虽然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。

那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。

用一张图来描述浅拷贝、深拷贝、引用拷贝:

java简答题考点 java简答题及答案_Java_03

11. == 和 equals() 的区别

==

  • 对于基本数据类型来说,== 比较的是值。
  • 对于引用数据类型来说,== 比较的是对象的内存地址。

因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。

equals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。

Object 类 equals() 方法:

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

equals() 方法存在两种使用情况:

  • 类没有重写 equals()方法 :通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
  • 类重写了 equals()方法 :一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回true(即,认为这两个对象相等)。

举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 == 换成 equals() ):

String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b为另一个引用,对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb);// true
System.out.println(a == b);// false
System.out.println(a.equals(b));// true
System.out.println(42 == 42.0);// true

String 中的 equals 方法是被重写过的,因为 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的

是对象的值。

当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如

果没有就在常量池中重新创建一个 String 对象。

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

12. equals()与hashcode()

12.1 hashCode() 有什么用?

hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。

hashCode()定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是: Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的,该方法通常用来将对象的内存地址转换为整数之后返回。

public native int hashCode();

散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)

12.2 为什么要有hashCode?

我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode?

下面这段内容摘自我的 Java 启蒙书《Head First Java》:

当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。

其实, hashCode() 和 equals()都是用于比较两个对象是否相等。

12.3 那为什么 JDK 还要同时提供这两个方法呢?

这是因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)!

我们在前面也提到了添加元素进HashSet的过程,如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。

12.4 那为什么不只提供 hashCode() 方法呢?

这是因为两个对象的hashCode 值相等并不代表两个对象就相等。

12.5 那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?

因为 hashCode() 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。

总结下来就是 :

  • 如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
  • 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。
  • 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。

相信大家看了我前面对 hashCode() 和 equals() 的介绍之后,下面这个问题已经难不倒你们了。

12.6 为什么重写 equals() 时必须重写 hashCode() 方法?

因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。

如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。

思考 :重写 equals() 时没有重写 hashCode() 方法的话,使用 HashMap 可能会出现什么问题。

总结 :

  • equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
  • 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。

更多关于 hashCode() 和 equals() 的内容可以查看:Java hashCode() 和 equals()的若干问题解答,讲的挺不错😄

13. String类

13.1 String、StringBuffer、StringBuilder 的区别?

可变性

String 是不可变的(后面会详细分析原因)。

StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存

字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法

比如 append 方法。

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    char[] value;
    public AbstractStringBuilder append(String str) {
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
    }
  	//...
}

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer
13.2 String 为什么是不可变的?

String 类中使用 final 关键字修饰字符数组来保存字符串,所以String 对象是不可变的。

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
	//...
}

🐛 修正 : 我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。

String 真正不可变有下面几点原因:

  1. 保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。
  2. String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。

相关阅读:如何理解 String 类型值的不可变? - 知乎提问open in new window。也不错,值得一看😄

补充(来自issue 675open in new window):在 Java 9 之后,String 、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。

public final class String implements java.io.Serializable,Comparable<String>, CharSequence { // @Stable 注解表示变量最多被修改一次,称为“稳定的”。 @Stable private final byte[] value; } abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; }

Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?

新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。

JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。

java简答题考点 java简答题及答案_面试_04

如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。

这是官方的介绍:https://openjdk.java.net/jeps/254 。

13.3 字符串拼接用“+” 还是 StringBuilder?

Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;

上面的代码对应的字节码如下:

java简答题考点 java简答题及答案_Java_05

可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。

String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
    s += arr[i];
}
System.out.println(s);

StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。

java简答题考点 java简答题及答案_Java_06

如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。

String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
    s.append(value);
}
System.out.println(s);

java简答题考点 java简答题及答案_面试_07

如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。

13.4 String equals() 和 Object equals() 有何区别?

String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。

13.5 字符串常量池的作用了解吗?

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。详情请在JVM中查看

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
13.6 String s1 = new String(“abc”);这句话创建了几个字符串对象?

会创建 1 或 2 个字符串对象。

1、如果字符串常量池中不存在字符串对象“abc”的引用,那么会在堆中创建 2 个字符串对象“abc”。

示例代码(JDK 1.8):

String s1 = new String("abc");

对应的字节码:

java简答题考点 java简答题及答案_java简答题考点_08

ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。

2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。

示例代码(JDK 1.8):

// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");

对应的字节码:

java简答题考点 java简答题及答案_java简答题考点_09

这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。

13.7 intern 方法有什么作用?

String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:

  • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
  • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

示例代码(JDK 1.8) :

// 在堆中创建字符串对象”Java“
// 将字符串对象”Java“的引用保存在字符串常量池中
String s1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s2 = s1.intern();
// 会在堆中在单独创建一个字符串对象
String s3 = new String("Java");
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s4 = s3.intern();
// s1 和 s2 指向的是堆中的同一个对象
System.out.println(s1 == s2); // true
// s3 和 s4 指向的是堆中不同的对象
System.out.println(s3 == s4); // false
// s1 和 s4 指向的是堆中的同一个对象
System.out.println(s1 == s4); //true
13.8 String 类型的变量和常量做“+”运算时发生了什么?

先来看字符串不加 final 关键字拼接的情况(JDK1.8):

String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";
String str4 = str1 + str2;
String str5 = "string";
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false

注意 :比较 String 字符串的值是否相等,可以使用 equals() 方法。 String 中的 equals 方法是被重写过的。 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是字符串的值是否相等。如果你使用 == 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals() 方法替换。

java简答题考点 java简答题及答案_父类_10

对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:

java简答题考点 java简答题及答案_java简答题考点_11

常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。

对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string"; 。

并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  • 基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。
  • final 修饰的基本数据类型和字符串变量
  • 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

引用的值在程序编译期是无法确定的,编译器无法对其进行优化。

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

String str4 = new StringBuilder().append(str1).append(str2).toString();

我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。

不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。

示例代码:

final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true

被 final 关键字修改之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。

如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。

示例代码(str2 在运行时才能确定其值):

final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
      return "ing";
}

14. 异常

14.1 Exception与Error有什么区别?

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:

  • Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
  • Error :Error 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
14.2 Checked Exception 和 Unchecked Exception 有什么区别?

Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。

比如下面这段 IO 操作的代码:

java简答题考点 java简答题及答案_父类_12

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有: IO 相关的异常、ClassNotFoundException 、SQLException…。

Unchecked Exception 即 不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException(空指针错误)
  • IllegalArgumentException(参数错误比如方法入参类型错误)
  • NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)
  • ArrayIndexOutOfBoundsException(数组越界错误)
  • ClassCastException(类型转换错误)
  • ArithmeticException(算术错误)
  • SecurityException (安全错误比如权限不够)
  • UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)

java简答题考点 java简答题及答案_java简答题考点_13

14.3 try-catch-finally
  • try块 : 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
  • catch块 : 用于处理 try 捕获到的异常。
  • finally 块 : 无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

代码示例1

try {
    System.out.println("Try to do something");
    throw new RuntimeException("RuntimeException");
} catch (Exception e) {
    System.out.println("Catch Exception -> " + e.getMessage());
} finally {
    System.out.println("Finally");
}

输出1

Try to do something
Catch Exception -> RuntimeException
Finally

示例代码2

public void test3() {
    try {
        System.out.println("hahahaha");
        return;
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        System.out.println("finally");
    }
}

输出2

hahahaha
finally

注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。

代码示例:

public void test2() {
    System.out.println(f(2));
}


public static int f(int value) {
    try {
        return value * value;
    } finally {
        if (value == 2) {
            return 0;
        }
    }
}

.class字节码文件

public void test2() {
    System.out.println(f(2));
}

public static int f(int value) {
    int var1;
    try {
        var1 = value * value;
    } finally {
        if (value == 2) {
            return 0;
        }

    }
    return var1;
}

输出:

0
14.4 finally 中的代码一定会执行吗?

不一定的!在某些情况下,finally 中的代码不会被执行。

就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。

try {
    System.out.println("Try to do something");
    throw new RuntimeException("RuntimeException");
} catch (Exception e) {
    System.out.println("Catch Exception -> " + e.getMessage());
    // 终止当前正在运行的Java虚拟机
    System.exit(1);
} finally {
    System.out.println("Finally");
}

输出:

Try to do something
Catch Exception -> RuntimeException

另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:

  1. 程序所在的线程死亡。
  2. 关闭 CPU。

建议使用try-with-resources语法糖来代替try-catch-finally

15. 反射与注解

15.1 反射的优缺点

优点 : 可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利

缺点 :让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的

15.2 获取 Class 对象的四种方式

如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:

1. 知道具体类的情况下可以使用:

Class alunbarClass = TargetObject.class;

但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化

2. 通过 Class.forName()传入类的全路径获取:

Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");

3. 通过对象实例instance.getClass()获取:

TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass();

4. 通过类加载器xxxClassLoader.loadClass()传入类路径获取:

ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");

通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行

15.3 注解的解析方法有哪几种?

注解只有被解析之后才会生效,常见的解析方法有两种:

  • 编译期直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
  • 运行期通过反射处理 :像框架中自带的注解(比如 Spring 框架的 @Value 、@Component)都是通过反射来进行处理的。

16. IO

16.1 序列化与反序列化
  • 序列化: 将数据结构或对象转换成二进制字节流的过程
  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
16.2 如果有些字段不想进行序列化怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
  • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。
16.3 常见的IO模型

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间。

UNIX 系统下, IO 模型一共有 5 种: 同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。

这也是我们经常提到的 5 种 IO 模型。

Java 中 3 种常见 IO 模型

BIO (Blocking I/O)

BIO 属于同步阻塞 IO 模型 。

同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。

java简答题考点 java简答题及答案_java_14

在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

NIO (Non-blocking/New I/O)

Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。

Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。

跟着我的思路往下看看,相信你会得到答案!

我们先来看看 同步非阻塞 IO 模型。

java简答题考点 java简答题及答案_面试_15

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。

相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。

但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。

这个时候,I/O 多路复用模型 就上场了。

java简答题考点 java简答题及答案_面试_16

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。

目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。

  • select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
  • epoll 调用 :linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。

IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。

Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

java简答题考点 java简答题及答案_Java_17

AIO (Asynchronous I/O)

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

java简答题考点 java简答题及答案_java_18

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。

java简答题考点 java简答题及答案_父类_19

17. 为什么Java中只有值传递?

程序设计语言将实参传递给方法(或函数)的方式分为两种:

  • 值传递 :方法接收的是实参值的拷贝,会创建副本。
  • 引用传递 :方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。

很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。

为什么说 Java 只有值传递呢? 不需要太多废话,我通过 3 个例子来给大家证明。

案例1:传递基本类型参数

代码:

public static void main(String[] args) {
    int num1 = 10;
    int num2 = 20;
    swap(num1, num2);
    System.out.println("num1 = " + num1);
    System.out.println("num2 = " + num2);
}

public static void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
    System.out.println("a = " + a);
    System.out.println("b = " + b);
}

输出

a = 20
b = 10
num1 = 10
num2 = 20

解析:

在 swap() 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。

java简答题考点 java简答题及答案_java简答题考点_20

通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看案例2。

案例2:传递引用类型参数1

代码:

public static void main(String[] args) {
    int[] arr = { 1, 2, 3, 4, 5 };
    System.out.println(arr[0]);
    change(arr);
    System.out.println(arr[0]);
}

public static void change(int[] array) {
    // 将数组的第一个元素变为0
    array[0] = 0;
}

输出

1
0

解析:

java简答题考点 java简答题及答案_java_21

看了这个案例很多人肯定觉得 Java 对引用类型的参数采用的是引用传递。

实际上,并不是的,这里传递的还是值,不过,这个值是实参的地址罢了!

也就是说 change 方法的参数拷贝的是 arr (实参)的地址,因此,它和 arr 指向的是同一个数组对象。这也就说明了为什么方法内部对形参的修改会影响到实参。

为了更强有力地反驳 Java 对引用类型的参数采用的不是引用传递,我们再来看下面这个案例!

案例3 :传递引用类型参数2

public class Person {
    private String name;
   // 省略构造函数、Getter&Setter方法
}

public static void main(String[] args) {
    Person xiaoZhang = new Person("小张");
    Person xiaoLi = new Person("小李");
    swap(xiaoZhang, xiaoLi);
    System.out.println("xiaoZhang:" + xiaoZhang.getName());
    System.out.println("xiaoLi:" + xiaoLi.getName());
}

public static void swap(Person person1, Person person2) {
    Person temp = person1;
    person1 = person2;
    person2 = temp;
    System.out.println("person1:" + person1.getName());
    System.out.println("person2:" + person2.getName());
}

解析:

怎么回事???两个引用类型的形参互换并没有影响实参啊!

swap 方法的参数 person1 和 person2 只是拷贝的实参 xiaoZhang 和 xiaoLi 的地址。因此, person1 和 person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhang 和 xiaoLi 。

java简答题考点 java简答题及答案_Java_22

总结

Java 中将实参传递给方法(或函数)的方式是 值传递 :

  • 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
  • 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。

18. 序列化与反序列化

18.1 实际开发中有哪些用到序列化和反序列化的场景?
  1. 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
  2. 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化。
  3. 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。
18.2 常见的序列化方式

JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且部分版本有安全漏洞。比较常用的序列化协议有 hessian、kyro、protostuff。

下面提到的都是基于二进制的序列化协议,像 JSON 和 XML 这种属于文本类序列化方式。虽然 JSON 和 XML 可读性比较好,但是性能较差,一般不会选择。

JDK自带的序列化方式

JDK 自带的序列化,只需实现 java.io.Serializable接口即可。

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class RpcRequest implements Serializable {
    private static final long serialVersionUID = 1905122041950251207L;
    private String requestId;
    private String interfaceName;
    private String methodName;
    private Object[] parameters;
    private Class<?>[] paramTypes;
    private RpcMessageTypeEnum rpcMessageTypeEnum;
}

序列化号 serialVersionUID 属于版本控制的作用。序列化的时候 serialVersionUID 也会被写入二级制序列,当反序列化时会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出 InvalidClassException 异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的序列化号。

我们很少或者说几乎不会直接使用这个序列化方式,主要原因有两个:

  1. 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
  2. 性能差 :相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。

Kryo

Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。

另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用。

guide-rpc-framework 就是使用的 kyro 进行序列化,序列化和反序列化相关的代码如下:

/**
 * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language
 *
 * @author shuang.kou
 * @createTime 2020年05月13日 19:29:00
 */
@Slf4j
public class KryoSerializer implements Serializer {

    /**
     * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
     */
    private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
        Kryo kryo = new Kryo();
        kryo.register(RpcResponse.class);
        kryo.register(RpcRequest.class);
        return kryo;
    });

    @Override
    public byte[] serialize(Object obj) {
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
             Output output = new Output(byteArrayOutputStream)) {
            Kryo kryo = kryoThreadLocal.get();
            // Object->byte:将对象序列化为byte数组
            kryo.writeObject(output, obj);
            kryoThreadLocal.remove();
            return output.toBytes();
        } catch (Exception e) {
            throw new SerializeException("Serialization failed");
        }
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> clazz) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
             Input input = new Input(byteArrayInputStream)) {
            Kryo kryo = kryoThreadLocal.get();
            // byte->Object:从byte数组中反序列化出对对象
            Object o = kryo.readObject(input, clazz);
            kryoThreadLocal.remove();
            return clazz.cast(o);
        } catch (Exception e) {
            throw new SerializeException("Deserialization failed");
        }
    }

}

Github 地址:https://github.com/EsotericSoftware/kryo

Protobuf

Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不然灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。

Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言

一个简单的 proto 文件如下

// protobuf的版本
syntax = "proto3";
// SearchRequest会被编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct
message Person {
  //string类型字段
  string name = 1;
  // int 类型字段
  int32 age = 2;
}

Github 地址:https://github.com/protocolbuffers/protobuf

ProtoStuff

由于 Protobuf 的易用性,它的哥哥 Protostuff 诞生了。

protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。

Github 地址:https://github.com/protostuff/protostuff

hessian

hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。hessian 是一个比较老的序列化实现了,并且同样也是跨语言的。

java简答题考点 java简答题及答案_Java_23

dubbo RPC 默认启用的序列化方式是 hessian2 ,但是,Dubbo 对 hessian2 进行了修改,不过大体结构还是差不多

总结

Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。