今天是 Java 基础常见八股文的第二篇,主要内容是关于面向对象的,非常重要,一点一点来看吧。

面向对象基础

面向对象和面向过程的区别?

  • ⾯向过程 :面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候再一个一个的一次调用就可以。
  • ⾯向对象 :面向对象,把构成问题的事务分解成各个对象,而建立对象的目的也不是为了完成一个个步骤,而是为了描述某个事件在解决整个问题的过程所发生的行为。 目的是为了写出通用的代码,加强代码的重用,屏蔽差异性。

用一个比喻:面向过程是编年体;面向对象是纪传体。

面向对象和面向过程的区别

面向对象编程有哪些特性?

面向对象编程有三大特性:封装、继承、多态。

二哥的 Java 进阶之路

  • 封装:封装是指将数据(属性,或者叫字段)和操作数据的方法(行为)捆绑在一起,形成一个独立的对象(类的实例)。

    封装是把一个对象的属性私有化,同时提供一些可以被外界访问的方法。

  • 继承:继承允许一个类(子类)继承现有类(父类或者基类)的属性和方法。以提高代码的复用性,建立类之间的层次关系。

    同时,子类还可以重写或者扩展从父类继承来的属性和方法,从而实现多态。

  • 多态:多态允许不同类的对象对同一消息做出响应,但表现出不同的行为(即方法的多样性)。

    多态其实是一种能力——同一个行为具有不同的表现形式;换句话说就是,执行一段代码,Java 在运行时能根据对象类型的不同产生不同的结果。

    多态的前置条件有三个:

    • 子类继承父类
    • 子类重写父类的方法
    • 父类引用指向子类的对象

多态解决了什么问题?

多态的目的是为了提高代码的灵活性和可扩展性,使得代码更容易维护和扩展。比如说动态绑定,允许在程序在运行时再确定调用的是子类还是父类的方法。

bigsai:封装继承多态

访问修饰符 public、private、protected、以及不写(默认)时的区别?

访问修饰符和可见性

抽象类和接口有什么区别?

一个类只能继承一个抽象类;但一个类可以实现多个接口。所以我们在新建线程类的时候一般推荐使用实现 Runnable 接口的方式,这样线程类还可以继承其他类,而不单单是 Thread 类。

抽象类符合 is-a 的关系,而接口更像是 has-a 的关系,比如说一个类可以序列化的时候,它只需要实现 Serializable 接口就可以了,不需要去继承一个序列化类。

抽象类更多地是用来为多个相关的类提供一个共同的基础框架,包括状态的初始化,而接口则是定义一套行为标准,让不同的类可以实现同一接口,提供行为的多样化实现。

抽象类可以定义构建函数吗?

可以,抽象类可以有构造方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public abstract void makeSound();
}

public class Dog extends Animal {
private int age;

public Dog(String name, int age) {
super(name); // 调用抽象类的构造函数
this.age = age;
}

@Override
public void makeSound() {
System.out.println(name + " says: Bark");
}
}

接口可以定义构建函数吗?

不能,接口主要用于定义一组方法规范,没有具体的实现细节。

二哥的 Java 进阶之路:接口不能定义构造方法

继承和抽象的区别?

继承是一种允许子类继承父类属性和方法的机制。通过继承,子类可以重用父类的代码。

抽象是一种隐藏复杂性和只显示必要部分的技术。在面向对象编程中,抽象可以通过抽象类和接口实现。

成员变量与局部变量的区别有哪些?

  1. 从语法形式上看:成员变量是属于类的,⽽局部变量是在⽅法中定义的变量或是⽅法的参数;成员变量可以被 public , private , static 等修饰符所修饰,⽽局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
  2. 从变量在内存中的存储⽅式来看:如果成员变量是使⽤ static 修饰的,那么这个成员变量是属于类的,如果没有使⽤ static 修饰,这个成员变量是属于实例的。对象存于堆内存,如果局部变量类型为基本数据类型,那么存储在栈内存,如果为引⽤数据类型,那存放的是指向堆内存对象的引⽤或者是指向常量池中的地址。
  3. 从变量在内存中的⽣存时间上看:成员变量是对象的⼀部分,它随着对象的创建⽽存在,⽽局部变量随着⽅法的调⽤⽽⾃动消失。
  4. 成员变量如果没有被赋初值:则会⾃动以类型的默认值⽽赋值(⼀种情况例外:被 final 修饰的成员变量也必须显式地赋值),⽽局部变量则不会⾃动赋值。

静态变量和实例变量的区别?静态方法、实例方法呢?

静态变量和实例变量的区别?

静态变量: 是被 static 修饰符修饰的变量,也称为类变量,它属于类,不属于类的任何一个对象,一个类不管创建多少个对象,静态变量在内存中有且仅有一个副本。

实例变量: 必须依存于某一实例,需要先创建对象然后通过对象才能访问到它。静态变量可以实现让多个对象共享内存。

静态⽅法和实例⽅法有何不同?

类似地。

静态方法:static 修饰的方法,也被称为类方法。在外部调⽤静态⽅法时,可以使⽤”类名.⽅法名“的⽅式,也可以使⽤”对象名.⽅法名“的⽅式。静态方法里不能访问类的非静态成员变量和方法。

实例⽅法:依存于类的实例,需要使用”对象名.⽅法名“的⽅式调用;可以访问类的所有成员变量和方法。

final、finally、finalize

  • final:final 是一个修饰符,可以修饰类、方法和变量。

    • 当 final 修饰一个类时,表明这个类不能被继承。比如,String 类、Integer 类和其他包装类都是用 final 修饰的。
    • final 修饰一个方法时,表明这个方法不能被重写(Override)。也就是说,如果一个类继承了某个类,并且想要改变父类中被 final 修饰的方法的行为,是不被允许的。
    • 当 final 修饰一个变量时,表明这个变量的值一旦被初始化就不能被修改。如果是基本数据类型的变量,其数值一旦在初始化之后就不能更改;如果是引用类型的变量,在对其初始化之后就不能再让其指向另一个对象。但是引用指向的对象内容可以改变。

    三分恶面渣逆袭:final修饰变量

  • finally: 是 Java 中异常处理的一部分,用来创建 try 块后面的 finally 块。无论 try 块中的代码是否抛出异常,finally 块中的代码总是会被执行。通常,finally 块被用来释放资源,如关闭文件、数据库连接等。

  • finalize: 是 object 类的一个方法,用于在垃圾回收器将对象从内存中清除出去之前作业写必要的清理工作。

    这个方法在垃圾回收器准备释放对象占用的内存之前被自动调用。我们不能显式地调用 finalize 方法,因为它总是由垃圾回收器在适当的时间自动调用。

深拷贝、浅拷贝

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

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

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

浅拷贝

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Address implements Cloneable{
private String name;
// 省略构造函数、Getter&Setter方法
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}

public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}

测试:

1
2
3
4
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());

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

深拷贝

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

1
2
3
4
5
6
7
8
9
10
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
person.setAddress(person.getAddress().clone());
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}

测试:

1
2
3
4
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());

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

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

我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:

浅拷贝、深拷贝、引用拷贝示意图

Java 是值传递,还是引用传递?

Java 是值传递,不是引用传递。

当一个对象被作为参数传递到方法中时,参数的值就是该对象的引用。引用的值是对象在堆中的地址。

对象是存储在堆中的,所以传递对象的时候,可以理解为把变量存储的对象地址给传递过去。

三分恶面渣逆袭:Java引用数据值传递示意图

引用类型的变量有什么特点?

引用类型的变量存储的是对象的地址,而不是对象本身。因此,引用类型的变量在传递时,传递的是对象的地址,也就是说,传递的是引用的值。

强引用、软引用、弱引用、虚引用

该问题考察 JVM、GC等知识。

  1. 强引用:只要引用关系还在,对象就永远不会被回收。强引用其实就是指普通对象的引用,只要还有引用关系存在,就表示对象还活着,垃圾回收器就无法回收这一类对象。只有在没有其他引用关系或者超过引用作用域,再或者将对象引用强制赋值为 null 的情况下,垃圾回收器才会回收这个对象。
  2. 软引用:非必须存活的对象,JVM 会在内存溢出前对其进行回收。软引用是一种相对于强引用来说弱一些的引用。可以让对象豁免一些垃圾回收的操作。只有当JVM判断内存不足的时候,才会试图回收引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足的时候就会清理掉。这样就可以保证在使用缓存的同时,不会耗尽内存。
  3. 弱引用:非必须存活的对象,不管内存是否够用,下次GC一定回收。弱引用是相对于强引用而言的,它是在允许存在引用关联的情况下能被回收的对象。在垃圾回收线程扫描它所管辖的内存区域的过程中,一旦发现只具有弱引用的对象,不管当前的内存空间是否足够,垃圾回收器都会回收这个对象。
  4. 虚引用:等同于没有引用,对象被回收时会收到通知。虚引用不会决定对象的生命周期,它提供一种确保对象被“finalize”以后去做某些事情的机制。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入与之关联的引用队列中,程序可以通过判断引用队列是否已经加入虚引用来决定被引用对象是否要被垃圾回收器回收。然后,我们就可以在引用对象被回收之前执行一些必要的操作。所以,虚引用必须和引用队列一起使用。

举个例子:

  • 强引用就好比电视剧中的男主角,怎么都死不了。
  • 软引用就像女主角,虽有一段经历,但还是没走到最后。
  • 弱引用就是男二号,注定是用来牺牲的。
  • 虚引用就是路人甲了。

Object 对象

Object 类的常见方法有哪些?

Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* native 方法,用于创建并返回当前对象的一份拷贝。
*/
protected native Object clone() throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
public String toString()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
public final native void notify()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
public final native void notifyAll()
/**
* native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
*/
public final native void wait(long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
*/
public final void wait(long timeout, int nanos) throws InterruptedException
/**
* 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
public final void wait() throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作
*/
protected void finalize() throws Throwable { }

Java 对象的创建过程

在实例化一个对象的时候,JVM 首先会去检查目标对象是否已经被加载并初始化了,如果没有,则 JVM 需要立刻去加载目标类,然后调用目标类的构造器完成初始化。目标类的加载是通过类加载器来实现的,主要就是把一个类加载到内存里面。

然后,初始化主要是对目标类里面的静态变量、成员变量、静态代码块进行初始化。当目标类被初始化以后,就可以从常量池里面找到对应的类元信息,并且目标对象的大小在类加载之后就已经确定了,所以这个时候就需要为新创建的对象,根据目标对象的大小在堆内存里面分配内存空间。

内存分配的方式一般有两种,一种是指针碰撞,另一种是空闲列表,JVM 会根据 Java 堆内存是否规整来决定内存分配方式。
接下来,JVM 会把目标对象里面的普通成员变量初始化为零值,比如 int 类型初始化为0.

对象类型初始化为null(类变量在类加载的准备阶段就已经初始化过了)。这一步操作主要是保证对象里面的实例字段不用初始化就可以直接使用,也就是程序能够获得这些字段对应数据类型的零值。

然后,JVM 还需要对目标对象的对象头做一些设置,比如对象所属的类元信息、对象的GC 分代年龄、hashCode、锁标记等。

完成这些步骤以后,对于 JVM水说,新对象的创建工作就完成了、但是对于 Java 语言来说,对象创建才算开始。

接下来要做的,就是执行目标对象内部生成的 init 方法,初始化成员变量的值、执行构造块,最后执行目标对象的构建方法,完成对象的构建。

其中,init 方法是 Java 文件编译之后在字节码文件中生成的,它是一个实例构造器,这个构造器会把语句块、变量初始化、调用父类构造器等操作组织在一起。所以调用 init 方法能够完成一系列初始化动作。

fb99e86aaf36945df1a34b11d4fd0da

==equals() 的区别

== 对于基本类型和引用类型的作用效果是不同的:

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

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

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

Objectequals() 方法:

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

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

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

hashCode

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

hashCode() 方法

hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:ObjecthashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。

1
public native int hashCode();

hashCode的值默认时 JVM 使用随机数生成的,两个不同的对象可能会生成相同的 hashCode

  • 为什么要提供 hashCode() 方法?

看《Head First Java》中的这一段:

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

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

  • 为什么要同时提供这两个方法?

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

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

  • 那为什么不只提供 hashCode 方法?

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

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

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

总结下来就是:

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

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

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

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

一个空的 Object 对象占多大内存?

image-20240612231739612

  1. 对象头,包括 Markword、类元指针、数组长度。其中 Markword 用来存储对象运行时的相关数据,比如 hashCode、GC 分代年龄等。在64位操作系统中占8字节,在32位操作系统中占4字节。类元指针指向当前实例对象所属哪个类,在开启压缩指针的情况下占4字节,未开启则占8字节。数组长度只有对象数组才会存在,占4字节。
  2. 实例数据,主要用来存储对象中的字段信息。
  3. 对齐填充,用来补充实现 Java 对象大小的倍数对齐。在JVM 中,Java 对象的大小需要按照8字节或者8字节的倍数来对齐,从而避免伪共享问题。

根据以上分析,我们来总结一下。

  1. 一个Java 空对象,在开启压缩指针的情况下,占用12字节。其中,Markword 占8字节、类元指针占4字节。但是为了避免伪共享问题,JVM会按照8字节的倍数进行填充,所以会在对齐填充区填充4字节,变成16字节。
  2. 在关闭压缩指针的情况下,Object 默认会占用16字节。其中,Markword 占8字节,类元指针占4字节,对齐填充占4字节。16字节正好是8的整数倍,因此不需要填充。

所以结论是,一般情况下,一个空的 Java Object 对象占用 16 字节的内存空间。

String

String、StringBuffer、StringBuilder 的区别?

值可变性

String 是不可变的。因此每次修改 String 的值,都会产生一个新的对象。

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 finalprivate 关键字修饰,所以都是可变类,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 中的对象是不可变的,也就可以理解为常量,线程安全。

AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能方面

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。性能最差。

StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。性能第二。

相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

数据存储方面

String 存储在字符串常量池中,StringBuilderStringBuffer 存储在堆内存中。

对于三者使用的总结:

  • 操作少量的数据: 适用 String
  • 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  • 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

String 为什么是不可变的?

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

1
2
3
4
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 不可变。

在 Java 9 之后,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。

1
2
3
4
5
6
7
8
9
10
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 可表示的字符。

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

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

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

1
2
3
4
String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;

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

img

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

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

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

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

img

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

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

img

不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了。这个改进是 JDK9 的 JEP 280open in new window 提出的,这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了。可以看看这篇文章

String s1 = new String(“hello”);这句话创建了几个字符串对象?|| 字符串常量池

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

1
2
3
4
5
6
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

String s1 = new String(“abc”);这句话创建了几个字符串对象?

会创建 一个 或 两个 字符串对象。看看详情:

首先,这个语句里面有一个 new关键字,这个关键字是在程序运行时,根据已经加载的系统类 String,在堆内存里面实例化的一个字符串对象,如下图所示。

144a58947a1f84578369ffbe81fde1d

然后,在这个 String 的构造方法里面,传递了一个 hello字符串,因为 String 里面的字符串成员变量是 final 修饰的,所以它是一个字符串常量。
接下来,JVM 会用字面量hello 去字符串常量池里面试图获取它对应的 String 对象引用,如果获取不到,就会在堆内存里面创建一个 hello 的 String 对象,并且把引用保存到字符串常量池里面。
后续如果再有字面量 hello 的定义,因为字符串常量池里面己经存在了字面量 hello的引用,所以只需要从常量池里面获取对应的引用就可以了,不需要再创建。
所以,对于这个问题,分以下两种情况:

  1. 如果 hello这个字符串常量不存在,则创建两个对象,分别是hello这个字符串常量,以及 new String
    这个实例对象。
  2. 如果 hello这个字符串常量存在,则只会创建一个对象。

看一段代码:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
String s3 = "he" + "llo";
String s4 = "hel" + new String("lo");
String s5 = new String("hello");
String s6 = s5.intern();
String s7 = "h";
String s8 = "ello";
String s9 = s7 + s8;
}

以上代码的输出结果为:

1
2
3
4
5
6
System.out.println(s1 == s2);//true
System.out.println(s1 == s3);//true
System.out.println(s1 == s4);//false
System.out.println(s1 == s9);//false
System.out.println(s4 == s5);//false
System.out.println(s1 == s6);//true

接下来简单分析一下,由于s2 指向的字面量 hello 在常量池中已经存在(s1 先于s2),于是JVM 就返回这个字面量绑定的引用,所以 s1 == s2

s3中字面量的拼接其实就是hello,在编译期间就已经对它进行了优化,所以s1 和 s3 也是相等的。

S4 中的new String(”lo”)生成了两个对象:lo 和 new String(”lo”)。lo存在于字符串常量池中,new String(”lo”)存在于堆中,String s4 = “hel” + new String(”lo”)实质上是两个对象的相加,编译器不会进行优化,相加的结果存在于堆中,而s1 存在于字符串常量池中,当然不相等。s1 == s9的原理也一样。

对于 s4 == s5,因为两个相加的结果都在堆中,不用说,肯定不相等。

对于 s1 == s6,s5.intern()方法能使一个位于堆中的字符串,在运行期间动态地加入字符串常量池(字符串常量池的内容是在程序启动的时候就已经加载好了的)。如果字符串常量池中有该对象对应的字面量,则返回该字面量在字符串常量池中的引用;否则,复制一份该字面量到字符串常量池并返回它的引用。因此s1 ==s6输出 true。

常量折叠

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

在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。

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

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

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

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

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

总结

本节内容比较杂乱,但基本都是 Java 基础中的重点,而且会对实际开发有很多启发,需要着重记忆。