Java基础常见面试题总结(中)2024/9/27

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

面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:
面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。

面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题

相比较于POP,OOP开发的程序一般具有下面这些优点

易维护:由于良好的结构和封装性,OOP程序通常更容易维护。

易复用:通过继承和多态,OOP设计使得代码更具复用性,方便扩展功能

易扩展:模块化设计使得系统扩展变得更加容易和灵活

POP的编程方式通常更为简单和直接,适合处理一些较简单的任务·

POP和OOP的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。

因此,简单地比较两者的性能是一个常见的误区(相关issue:面向过程:面向过程性能比面向对象高??)

                                                                pop和oop性能比较不合适

在选择编程范式时,性能并不是唯一的考虑因素。

代码的可维护性,可扩展性和开发效率同样重要。

现代编程语言基本都支持多种编程范式,即可以用来进行面向过程编程,也可以进行面向对象编程。

下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。

面向对象:

public class Circle {
    // 定义圆的半径
    private double radius;
    
    // 构造函数
    public Circle(double radius){
       this.radius = radius;
    }
    
    // 计算圆的面积
    public double getArea(){
        return Math.PI * radius * radius
    }
    
    //计算圆的周长
    public double getPerimeter(){
        return 2 * Math.PI * radius;
    }
    
    public static void main(String[] args){
        // 创建一个半径为3的圆
        Circle Circle = new Circle(3.0);
        
        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + circle.getArea());
        System.out.println("圆的周长为: " + circle.getPerimeter());
    } 
}

 我们定义一个Circle类来表示圆,该类包含了圆的半径和计算面积,周长的方法

面向过程:

public class Main {
    public static void main(String[] args) {
        // 定义圆的半径
        double radius = 3.0
        
        // 计算圆的面积和周长
        double area = Math.PI * radius * radius
        doyble perimeter = 2 * Marh.PI * radius;

        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + area);
        System.out/println("圆的周长为:" + perimeter);
    }
}

我们直接定义了圆的半径,并使用该半径直接计算出元的面积和周长。

创建一个对象用什么运算符?

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

new运算符,new创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)
一个对象引用可以指向0个或1个对象(一根绳子可以不系气球,也可以系一个气球);

一个对象可以有n个引用指向它(可以用n条绳子系住一个气球)

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

对象的相等一般比较的是内存中存放的内容是否相等

引用相等一般比较的是他们指向的内存地址是否相等

这里举个例子:

String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// 使用 == 比较字符串的引用相等
System.out.println(str1 == str2);
System.out.println(str1 == str2);
// 使用equals 方法比较字符串的相等
System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));

输出结果:

false
true
true
true

从上面的代码输出结果可以看出:

str1和str2不相等,而str1和str3相等。

这是因为equals方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要他们的内容相等,就认为他们是相等的。

如果一个类没有声明构造方法,该程序能正确执行吗?2024-9-28

构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。

如果一个类没有声明构造方法,也可以执行!

因为一个类即时没有声明构造方法也会有默认的不带参数的构造方法。

如果我们自己添加了类的构造方法,(无论是否有参),Java就不会添加默认的无参数的构造方法了。

我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加入一个括号(因为要调用无参的构造方法)。

如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。

构造方法有那些特点?是否可被override?

构造方法具有以下特点:

名称与类名相同:构造方法的名称必须与类名完全一致。

没有返回值:构造方法没有返回类型,且不能使用void声明

自动执行:在生成类的对象时,构造方法会自动执行,无须显示调用

构造方法不能重写(override),但可以被重载(overload).

因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。

面向对象三大特性

 封装

封装是指把一个对象的状态信息(也就是属性)隐藏的对象内部,不允许外部对象直接访问对象的内部信息。

但是可以提供一些可以被外界访问的方法来操作属性。

就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。

如果属性不想被外界访问,我们大可不比提供方法给外界访问。

但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。

就好像如果没有空调遥控器,那么我们就无法操控空调制冷,空调本身就没有意义了(当然现在还有很多其他方法,这里只是为了举例子)。

public class Student{
    private int id;//id属性私有化
    private String name;//name属性私有化

    //获取id的方法
    public int getId(){
        return id;
    }

    //设置id的方法
    public void setId(int id) {
        this.id = id;
    }

    //获取name的方法
    public String getName(){
        return name;
    }
    
    //设置name的方法
    public void setName(String name) {
        this.name = name;
    }
}

继承

不同类型的对象,相互之间经常有一定数量的共同点。

例如,小明同学,小红同学,小李同学,都共享学生的特性(班级,学号等)。

同时,每一个对象还定义了额外的特性使得他们与众不同。

例如小明的数学比较好,小红的性格惹人喜爱;

小李的力气比较大。

继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。

通过使用继承,可以快速地创建新的类们可以提高代码的重用,程序的可维护性,节省大量创建新类的时间,提高代码的重用,程序的可维护性,节省大量创建类的时间,提供我们的开发效率。

关于继承如下3点请记住:

1.子类拥有父类对象的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问的,只是拥有。

2.子类可以拥有自己属性和方法,即子类可以对父类进行扩展

3.子类可以用自己的方式实现父类的方法。

(以后介绍)。

多态

多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

多态的特点:

对象类型和引用类型之间具有继承(类)/实现(接口)的关系

引用类型变量发出的方法调用的到底是那个类中的方法,必须在程序运行期间才能确定;

多态不能调用“只在子类存在但在父类不存在”的方法;

如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。

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

 接口和抽象类的共同点

实例化:接口和抽象类都不能直接实现,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。

抽象方法:接口和抽象类都可以包含抽象方法。

抽象方法没有方法体,必须在子类后实现类中实现.

接口和抽象类的区别

设计目的:

接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。

抽象类主要用于代码复用,强调的是所属关系。

继承和实现:一个类只能继承一个类(包括抽象类),因为Java不支持多继承。

但一个类可以实现多个接口,一个接口也可以继承多个其他接口。

成员变量:接口中的成员变量只能是也默认是public static final类型的,不能被修改且必须有初始值。

(没有初始值的原因是它是final,final都是没有默认值的)。

抽象类的成员变量可以有任何修饰符(private,protected,public),可以在子类中被重新定义或赋值。

(就是没啥限制)

方法:

Java8之前,接口中的方法默认是public abstract,也就是只能有方法声明。

自java8起,可以在接口中定义default(默认(显式))方法和static(静态)方法。

自Java9起,接口可以包含private方法。

抽象类可以包含抽象方法和非抽象方法。

抽象方法没有方法体,必须在子类中实现。

非抽象方法有具体实现,可以直接在抽象类中使用或子类中使用或重写。

在Java8及以上版本中,接口引入了新的方法类型:default方法,static方法和private方法。

这些方法让接口使用更加灵活。

Java8引入的default方法用于提供接口方法的默认实现,可以在实现类中被覆盖。

这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。

public interface MyInterface(){
    default void defaultMethod(){
        System.out.println("This is a default method.");
    }
}

Java8引入的static方法无法再实现类中被覆盖,只能通过接口名直接调用(MyInterface.staticMethod()),类似于类中的静态方法。

static方法通常用于定义一些通用的,与接口相关的工具方法,一般很少用。

public interface MyInterface {
    static void staticMethod(){
        System.out.println("This is a static method in the interface.");
    }
}

Java9允许在接口中使用private方法。

private方法可以用于在接口内部共享代码,不对外暴露。

public interface MyInterface {
    // default 方法
    default void defaultMethod(){
        commonMethod();
    }

    // static 方法
    static void staticMethod() {
        commonMethod();
    }
    
    // 私有静态方法,可以被 static 和 default 方法 
    private static void commonMethod() {
        System.out.println("This is a private method used internally.")
    }

    // 实例私有方法,只能被 default 方法调用
    private void instanceCommonMethod(){
        System.out.println("This is a private instance method used internally.");
    }
}

深拷贝和浅拷贝区别了解吗?什么是引用拷贝?2024/9/28
 

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

浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象混入原对象共用同一个内部对象。

深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

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

浅拷贝

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

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

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();
        }
    }    
}

测试

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对象一起复制。

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

测试:

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

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

那什么是引入拷贝呢?

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

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

Object类的常见方法有那些?

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

 

/**
 * 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 { }

==和equals()的区别2114

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

对于基本数据类型来说,==比较的是值

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

因为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对象

String类equals()方法:

public boolean equals(Object anObject) {
    if (this == anObject) {
        return truel;
    }
    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;
}

hashCode()有什么用?2114

hashCode()的作用是获取哈希码(int整数),也称为散列码。

这个哈希码的作用是确定该对象在哈希表中的索引位置


                                hashCode()方法

hashCode()定义在JDK的Object类中,这就意味着Java中的任何类都包含hashCode()函数。

另外需要注意的是:Object的hashCode()方法是本地方法,也就是用C语言或C++实现的。

注意:该方法在Oracle OpenJDK8中默认是“使用线程局部状态来实现Marsagkia's xor-shift随机数生成”,并不是“地址”或者“地址转换而来”,不同JDK/VM可能不同在Oracle OpenJDK8中有六种生方式(其中第五种是返回地址),通过添加VM参数:-XX:hashCode=4启用第5种。

参考源码:

https://hg.openjdk/org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/globas.hpp(1127行)

https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp(537行开始)

public native int hashCode();

散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。

这其中就利用了散列码!

(可以快速找到所需要的对象)

为什么要有hashCode?2131

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

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

当你把对象加入HashSet时,HashSet会先计算对象的hashCode值来判断对象加入的位置,同时也会与其他已经加入的对象hashCode值作出比较,如果没有相符的hashCode,HashSet会假设对象没有重复出现。

但是如果发现有相同hashCode值的对象,这时会调用equals()方法来检查hashCode相等的对象是否真的相同。

如果两者相同,HashSet就不会让其加入操作成功。

如果不同的话,就会重新散列到其他位置。

这样我们就大大减少了equals的次数,相应就大大提高了执行速度。

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

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

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

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

也就是说hashCode帮助我们大大缩小查找成本。

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

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

那为什么两个对象有相同的hashCode值,他们也不一定是相等的?
因为hashCode()所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。

月糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是值的是不同的对象得到相同的hashCode)

总结下来就是:
如果两个对象的hashCode值相等,那这两个对象不一定相等(哈希碰撞)

如果两个对象的hashCode值相等并且equals()方法返回true,我们才认为两个对象相等。

如果两个对象的hashCode值不相等,我们就可以直接认为这两个对象不相等。

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

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

因为两个相等对象的hashCode值必须相等。

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

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

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

总结:

equals方法判断两个对象是相等的,那这两个对象的hashCode值也要相等

两个对象有相同的hashCode值,他们也不一定是相等的(哈希碰撞)

更多关于hashCode()和equals()的内容可以查看Java hashCode()和equals()的若干问题解答

String

String,StringBuffer,StringBuilder的区别?

可变性

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

StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,不过没有使用final和private关键字修饰,最关键的是这个AbstractStringBuilder类还提供了很多修改字符串的方法比如append方法。

abstract class AbstractStringBuilder implement 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中的对象是不可变的,也就是可以理解为常量,线程安全。

AbatractStringBuilder是StringBuilder与StringBuffer的公共父类,定义了一些字符串的基本操作,如expandCapacity,append,insert,indexOf等公共方法。

StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。

StringBuilder并没有方法进行加同步锁,所以是非线程安全的。

性能

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

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

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

对于三者使用的总结:

操作少量的数据:适用String

单线程操作字符串缓冲区下操作大量数据:适用StringBuilder

多线程操作字符串缓冲区下操作大量数据:适用StringBuffer1

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类型值的不可变?-知乎提问

补充(来自issue675):在Java9之后,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;
}

Java9为何要将String的底层实现由char[]改为byte[]?

新版的String其实支持t两个编码方案:Latin-1和UTF-16.

如果字符串中包含的汉字没有超过Latin-1可表示范围的字符,那就会使用Latin-1作为编码方案。

Latin-1编码方案下,byte占一个字节(8位),char占用2个字节(16),byte相较char节省一半的内存空间。

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

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

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

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

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

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

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

可以看出 ,字符串对象通过“+”的字符串拼接方式,实际上是通过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对象

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

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

 

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

在JDK9中,字符串相加“+”改为用动态方法makeConcatWithConstats()来实现,通过提前分配空间从而减少了部分临时对象的创建。

然而这种优化主要针对简单的字符串拼接,如:a+b+c。

对于循环中的大量拼接操作,仍然会逐个动态分配内存(类似于两个两个append的概率),并不如手动使用StringBuilder来进行拼接效率高。

这个改进是JDK9的JEP280提出的,关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用StringBuilder?来重温一下字符串拼接把以及参考issue#2442

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

String 中的equals方法是被重写过的,比较的是String字符串的值是否相等。

Object的equals方法是比较的对象的内存地址。

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

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

// 在字符串常量池中创建字符串对象"ab"
// 将字符串对象"ab"的引用赋值给aa
String aa = "aa";
// 直接返回字符串常量池中字符串对象"ab",赋值给引用bb
String bb = "ab";
System.out.println(aa==bb);//true

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

先说答案:会创建1或2个字符串对象

1.字符串常量池中不存在"abc":会创建2个字符串对象。

一个在字符串常量池中,有ldc指令触发创建。

一个在堆中,有new String()创建,并使用常量池中的“abc”进行初始化。

2.字符串常量池池中已存在“abc”:会创建一个字符串对象。

该对象在堆中,由new String()创建,并使用常量次中的“abc”进行初始化。

下面开始详细分析。

1.如果字符串常量池中不存在字符串对象“abc”,那么它首先会在字符串常量池中创建字符串对象“abc”,然后再堆内存中再创建其中一个字符串对象“abc”

示例代码(JDK1.8):

String s1 = new String("abc");

对应的字节码

// 在堆内存中分配一个尚未初始化的String对象
// #2是常量池中的一个符合引用,指向java/lang/String类
// 在类加载的解析阶段,这个符合引用会被解析成直接引用,即指向实际的 java/lang/String 类。
0 new #2 <java/lang/String>
// 复制栈顶的 String 对象引用,为后续的构造函数调用做准备
// 此时操作数栈中有两个相同的对象引用:一个用于传递给构造函数,另一个用于保持堆新对象的引用,后续将其存储到局部变量表。
3 dup
// JVM 先检查字符串常量池中是否存在“abc”
// 如果常量池中已存在“abc”,则直接返回该字符串的引用;
// 如果常量池中不存在“abc”,则JVM会在常量池中创建该字符串字面量并返回它的引用。
// 这个引用被压入操作数栈,用作构造函数的参数
4 ldc #3 <abc>
// 调用构造方法,使用从常量池中加载的“abc”初始化堆中的String对象
// 新的String对象将包含与常量池中的"abc"相同的内容,但他是一个独立的对象,存储与堆中。
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 将堆中的String对象引用存储到局部变量表
9 astore_1
// 返回,结束方法
10 return

ldc(load constant)指令的确是从常量池中加载各种类型的常量,包括字符串常量,整数常量,浮点数常量,甚至类引用等。

对于字符串常量,ldc指令的行为如下:

1.从常量池加载字符串:ldc首先检查字符串常量池中是否已经有内容相同的字符串对象

2.复用已有字符串对象:如果字符串常量池中已经存在内容相同的字符串对象,ldc会将该对象的引用加载到操作数栈上。

3.没有则创建新对象并加入常量池:如果字符串常量池中没有相同内容的字符串对象,JVM会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。

2.如果字符串常量池中已经存在字符串对象“abc”,则只会在堆中创建1个字符串对象“abc”

示例代码(JDK1.8):

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

对应的字节码:
 

0 ldc #2 <abc>
2 astore_1
3 new #3 <java/lang/String>
6 dup
7 ldc #2 <abc>
9 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
12 atrore_2
13 return

这里就不对上面的字节码进行详细注释了,7z这个位置的ldc命令不会在堆中创建新的字符串对象“abc”,这是因为o这个位置已经执行了一次ldc命令,已经在堆中创建过一次字符串对象“abc”了。

7这个位置执行ldc命令会直接返回字符串常量池中字符串对象“abc”对应的引用。

String#intern方法有什么作用?

String.intern()是一个native(本地)方法,用于处理字符串常量池中的字符串对象引用。

它的工作流程可以概括为以下两种情况:

1.常量池中已有相同内容的字符串对象:如果字符串常量池中已经有一个与调用intern()方法的字符串内容相同的String对象,intern()方法会直接返回常量池中该对象的引用。

2.常量池中没有相同内容的字符串对象:如果字符串常量池中还没有一个与调用intern()方法的字符串内容相同的对象,intern()方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。

总结:

intern()方法的主要作用是确保字符串引用的常量池中的唯一性。

当调用intern()时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;

否则将该字符串添加到常量池并返回其引用。

示例代码(JDK1.8):

// s1指向字符串常量池中的“Java”对象
String s1 = "Java";
// s2也指向字符串常量池中的“Java”对象,和s1是同一个对象
String s2 = s1.intern();
// 在堆中创建一个新的“Java”对象,s3指向它
String s3 = new String("Java");
// s4指向字符串常量池中的“Java”对象,和s1是同一个对象
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

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中的equlas方法是被重写过的。

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

如果你使用==比较两个字符串是否相等的话,IEDA还是提升你使用equals()方法替换、

对于编译器可以确定值的字符串,也就是常量字符串,jvm会将其存入字符串常量池。

并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

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

《深入理解Java虚拟机》中是也有介绍 

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

对于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);// 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";
}

参考

深入解析String#intern:https://tech/meituan.com/2014/03/06/in-depth-understanding-string-intern.html

Java String源码解读:http://keaper.cn/2020/09/08/java-string-mian-mian-guan/

R大(RednaxelaFX)关于常量折叠的回答:

https://www.zhihu.com/question/55976094/answer/147302764

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值