面试八股题

一、Java篇

1、Java和C++主要区别有哪些?各有哪些优缺点?

C++是编译型语言(首先将源代码编译生成机器码,再由机器运行机器码),执行速度快、效率高;依赖编译器、跨平台性差些。

Java是解释型语言(源代码不是直接翻译成机器语言,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。),执行速度慢、效率低;依赖解释器、跨平台性好。

|------------|------------|
| Java | C++ |
| 平台无关 | 平台有关 |
| 自动 | 手动 |
| 值传递 | 引用、指针、值传递 |
| 不支持 | 支持 |
| 弱 | 强 |
| 企业级Web应用开发 | 系统编程、游戏开发等 |

2、如何理解面向对象和面向过程?

面向过程把问题分解成一个一个步骤,每个步骤用函数实现,依次调用即可。

我们在进行面向过程编程的时候,不需要考虑那么多,上来先定义一个函数,然后使用各种诸如if-else、for-each等方式进行代码执行。最典型的用法就是实现一个简单的算法,比如实现冒泡排序。

面向对象将问题分解成一个一个步骤,对每个步骤进行相应的抽象,形成对象,通过不同对象之间的调用,组合解决问题。

就是说,在进行面向对象编程的时候,要把属性、行为等封装成对象,然后基于这些对象及对象的能力进行业务逻辑的实现。比如想要造一辆车,上来要先把车的各种属性定义出来,然后抽象成一个Car类。

面向对象有封装、继承、多态三大基本特征,和单一职责原则、开放封闭原则、Liskov替换原则、依赖倒置原则和 接口隔离原则等五大基本原则。

扩展知识

面向对象的三大基本特征?

三大基本特征:封装、继承、多态。

封装就是把现实世界中的客观事物抽象成一个Java类,然后在类中存放属性和方法。如封装一个汽车类,其中包含了发动机、轮胎 、底盘等属性,并且有启动、前进等方法。

像现实世界中儿子可以继承父亲的财产、样貌、行为等一样,编程世界中也有继承,继承的主要目的就是为了复用。子类可以继承父类,这样就可以把父类的属性和方法继承过来。

如Dog类可以继承Animal类,继承过来嘴巴、颜色等属性, 吃东西、奔跑等行为。

多态是指在父类中定义的方法被子类继承之后,可以通过重写,使得父类和子类具有不同的实现,这使得同一个方法在父类及其各个子类中具有不同含义。

继承和实现

在Java中,接口可以继承接口,抽象类可以实现接口,抽象类也可以继承具体类。普通类可以实现接口,普通类也可以继承抽象类和普通类。

Java支持多实现,但是只支持单继承。即一个类可以实现多个接口,但是不能继承多个类。

面向对象的五大基本原则?
  • 单一职责原则(Single-Responsibility Principle
    • 内容:一个类最好只做一件事
    • 提高可维护性:当一个类只负责一个功能时,其实现通常更简单、更直接,这使得理解和维护变得更容易。
    • 减少代码修改的影响:更改影响较小的部分,因此减少了对系统其他部分的潜在破坏。
  • 开放封闭原则(Open-Closed principle)
    • 内容:对扩展开放、对修改封闭
    • 促进可扩展性:可以在不修改现有代码的情况下扩展功能,这意味着新的功能可以添加,而不会影响旧的功能。
    • 降低风险:由于不需要修改现有代码,因此引入新错误的风险较低。
  • Liskov替换原则(Liskov-Substituion Principle)
    • 内容:子类必须能够替换其基类
    • 提高代码的可互换性:能够用派生类的实例替换基类的实例,使得代码更加模块化,提高了其灵活性。
    • 增加代码的可重用性:遵循LSP的类和组件更容易被重用于不同的上下文。
  • 依赖倒置原则(Dependency-Inversion Principle)
    • 内容:程序要依赖于抽象接口,而不是具体的实现
    • 提高代码的可测试性:通过依赖于抽象而不是具体实现,可以轻松地对代码进行单元测试。
    • 减少系统耦合:系统的高层模块不依赖于低层模块的具体实现,从而使得系统更加灵活和可维护。
  • 接口隔离原则(Interface-Segregation Principle)。
    • 内容:使用多个小的专门的接口,而不要使用一个大的总接口
    • 减少系统耦合:通过使用专门的接口而不是一个大而全的接口,系统中的不同部分之间的依赖性减少了。
    • 提升灵活性和稳定性:更改一个小接口比更改一个大接口风险更低,更容易管理。

3、为什么Java不支持多继承?

因为如果要实现多继承,就会像C++中一样,存在菱形继承的问题,C++为了解决菱形继承问题,又引入了虚继承。因为支持多继承,引入了菱形继承问题,又因为要解决菱形继承问题,引入了虚继承。而经过分析,人们发现我们其实真正想要使用多继承的情况并不多。所以,在 Java 中,不允许"多继承",即一个类不允许继承多个父类。

除了菱形的问题,支持多继承复杂度也会增加。一个类继承了多个父类,可能会继承大量的属性和方法,导致类的接口变得庞大、难以理解和维护。此外,在修改一个父类时,可能会影响到多个子类,增加了代码的耦合度。

在Java 8以前,接口中是不能有方法的实现的。所以一个类同时实现多个接口的话,也不会出现C++中的歧义问题。因为所有方法都没有方法体,真正的实现还是在子类中的。但是,Java 8中支持了默认函数(default method ),即接口中可以定义一个有方法体的方法了。

而又因为Java支持同时实现多个接口,这就相当于通过implements就可以从多个接口中继承到多个方法了,但是,Java8中为了避免菱形继承的问题,在实现的多个接口中如果有相同方法,就会要求该类必须重写这个方法

扩展知识

菱形继承问题

Java的创始人James Gosling曾经回答过,他表示:

"Java之所以不支持一个类继承多个类,主要是因为在设计之初我们听取了来自C++和Objective-C等阵营的人的意见。因为多继承会产生很多歧义问题。"

Gosling老人家提到的歧义问题,其实是C++因为支持多继承之后带来的菱形继承问题。

假设我们有类B和类C,它们都继承了相同的类A。另外我们还有类D,类D通过多重继承机制继承了类B和类C。

这时候,因为D同时继承了B和C,并且B和C又同时继承了A,那么,D中就会因为多重继承,继承到两份来自A中的属性和方法。

这时候,在使用D的时候,如果想要调用一个定义在A中的方法时,就会出现歧义。

因为这样的继承关系的形状类似于菱形,因此这个问题被形象地称为菱形继承问题。

而C++为了解决菱形继承问题,又引入了虚继承。

因为支持多继承,引入了菱形继承问题,又因为要解决菱形继承问题,引入了虚继承。而经过分析,人们发现我们其实真正想要使用多继承的情况并不多。

所以,在 Java 中,不允许"声明多继承",即一个类不允许继承多个父类。但是 Java 允许"实现多继承",即一个类可以实现多个接口,一个接口也可以继承多个父接口。由于接口只允许有方法声明而不允许有方法实现(Java 8之前),这就避免了 C++ 中多继承的歧义问题。

Java 8中的多继承

Java不支持多继承,但是是支持多实现的,也就是说,同一个类可以同时实现多个接口。

我们知道,在Java 8以前,接口中是不能有方法的实现的。所以一个类同时实现多个接口的话,也不会出现C++中的歧义问题。因为所有方法都没有方法体,真正的实现还是在子类中的。

Java并没有帮我们解决多继承的歧义问题,而是把这个问题留给开发人员,通过重写方法的方式自己解决。

4、接口和抽象类的区别,如何选择?

接口(Interface)和抽象类(Abstract Class)是面向对象编程中两个非常重要的概念,它们都可以用来实现抽象层。

**方法定义:**接口和抽象类,最明显的区别就是接口只是定义了一些方法而已,在不考虑Java8中default方法情况下,接口中只有抽象方法,是没有实现的代码的。(Java8中可以有默认方法)

修饰符:抽象类中的修饰符可以有public、protected和private和<default>这些修饰符,而接口中默认修饰符是public。不可以使用其它修饰符。(接口中,如果定义了成员变量,还必须要初始化)

构造器:抽象类可以有构造器。接口不能有构造器。

继承和实现:接口可以被实现,抽象类可以被继承。

单继承,多实现:一个类可以实现多个接口,但只能继承一个抽象类。接口支持多重继承,即一个接口可以继承多个其他接口。

职责不同 :接口和抽象类的职责不一样。接口主要用于制定规范 ,因为我们提倡也经常使用的都是面向接口编程。而抽象类主要目的是为了复用,比较典型的就是模板方法模式。

所以当我们想要定义标准、规范的时候,就使用接口。当我们想要复用代码的时候,就使用抽象类。

一般在实际开发中,我们会先把接口暴露给外部,然后在业务代码中实现接口。如果多个实现类中有相同可复用的代码,则在接口和实现类中间加一层抽象类,将公用部分代码抽出到抽象类中。可以参考下模板方法模式,这是一个很好的理解接口、抽象类和实现类之间关系的设计模式。

5、如何理解Java中的多态?

多态的概念比较简单,就是同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。

如果按照这个概念来定义的话,那么多态应该是一种运行期的状态。为了实现运行期的多态,或者说是动态绑定,需要满足三个条件:

  • 有类继承或者接口实现。
  • 子类要重写父类的方法。
  • 父类的引用指向子类的对象。

扩展知识

方法的重载与重写

重载是就是函数或者方法有同样的名称,但是参数列表不相同的情形,这样的同名不同参数的函数或者方法之间,互相称之为重载函数或者方法。

重写指的是在Java的子类与父类中有两个名称、参数列表都相同的方法的情况。由于他们具有相同的方法签名,所以子类中的新方法将覆盖父类中原有的方法。

重载和重写的区别
  1. 重载是一个编译期概念、重写是一个运行期间概念。
  2. 重载遵循所谓"编译期绑定",即在编译时根据参数变量的类型判断应该调用哪个方法。
  3. 重写遵循所谓"运行期绑定",即在运行的时候,根据引用变量所指向的实际对象的类型来调用方法

6、Java中有了基本类型为什么还需要包装类?

Java中有8种基本数据类型,这些基本类型又都有对应的包装类。

|-----|---------|-----------|-----|--------------------------------------------------------|
| 分类 | 基本数据类型 | 包装类 | 长度 | 表示范围 |
| 布尔型 | boolean | Boolean | / | / |
| 整型 | byte | Byte | 1字节 | -128 到 127 |
| 整型 | short | Short | 2字节 | -32,768 到 32,767 |
| 整型 | int | Integer | 4字节 | -2,147,483,648 到 2,147,483,647 |
| 整型 | long | Long | 8字节 | -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 |
| 字符型 | char | Character | 2字节 | Unicode字符集中的任何字符 |
| 浮点型 | float | Float | 4字节 | 约 -3.4E38 到 3.4E38 |
| 浮点型 | double | Double | 8字节 | 约 -1.7E308 到 1.7E308 |

因为Java是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将int 、double等类型放进去的。因为集合的容器要求元素是Object类型。

为了让基本类型也具有对象的特征,就出现了包装类型,它相当于将基本类型"包装起来",使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

知识扩展

基本类型和包装类型的区别
  1. 默认值不同,基本类型的默认值为0, false或\u0000等,包装类默认为null
  2. 初始化方式不同,一个需要new,一个不需要
  3. 存储方式不同,基本类型保存在栈上,包装类对象保存在堆上(成员变量的话,在不考虑JIT优化的栈上分配时,都是随着对象一起保存在堆上的)

简单介绍一下JIT优化技术:见JVM篇4

接口定义中应该用什么?

RPC接口返回中,使用基本类型还是包装类:见本篇16

如何理解自动拆装箱

拆箱与装箱

包装类是对基本类型的包装,所以,把基本数据类型转换成包装类的过程就是装箱;反之,把包装类转换成基本数据类型的过程就是拆箱。

自动拆装箱

在Java SE5中,为了减少开发人员的工作,Java提供了自动拆箱与自动装箱功能。

自动装箱: 就是将基本数据类型自动转换成对应的包装类。

自动拆箱:就是将包装类自动转换成对应的基本数据类型。

自动拆装箱原理

自动装箱都是通过包装类的valueOf()方法来实现的.自动拆箱都是通过包装类对象的xxxValue()来实现的。

如:int的自动装箱都是通过Integer.valueOf()方法来实现的,Integer的自动拆箱都是通过Integer.intValue()来实现的。

哪些地方会自动拆装箱
  1. 场景一、将基本数据类型放入集合类
  2. 场景二、包装类型和基本类型的大小比较
  3. 场景三、包装类型的运算
  4. 场景四、三目运算符的使用
  5. 场景五、函数参数与返回值

自动拆装箱与缓存

Java SE的自动拆装箱还提供了一个和缓存有关的功能,

我们只需要知道,当需要进行自动装箱时,如果数字在-128至127之间时,会直接使用缓存中的对象,而不是重新创建一个对象。

其中的javadoc详细的说明了缓存支持-128到127之间的自动装箱过程。最大值127可以通过-XX:AutoBoxCacheMax=size修改。

实际上这个功能在Java 5中引入的时候,范围是固定的-128 至 +127。后来在Java 6中,可以通过java.lang.Integer.IntegerCache.high设置最大值。

这使我们可以根据应用程序的实际情况灵活地调整来提高性能。到底是什么原因选择这个-128到127范围呢?因为这个范围的数字是最被广泛使用的。 在程序中,第一次使用Integer的时候也需要一定的额外时间来初始化这个缓存。

7、为什么不能用浮点数表示金额?

因为不是所有的小数都能用二进制表示(扩展知识中介绍为啥不能表示),所以,为了解决这个问题,IEEE提出了一种使用近似值表示小数的方式,并且引入了精度的概念。这就是我们所熟知的浮点数。

比如0.1+0.2 != 0.3,而是等于0.30000000000000004 (甚至有一个网站就叫做 Floating Point Math ,就是来解释这个现象的)

所以,浮点数只是近似值,并不是精确值,所以不能用来表示金额。否则会有精度丢失。

扩展知识

十进制转二进制

十进制整数转换为二进制整数采用"除2取余,逆序排列"法。

具体做法是:

  • 用2整除十进制整数,可以得到一个商和余数;
  • 再用2去除商,又会得到一个商和余数,如此进行,直到商为小于1时为止
  • 然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。

十进制小数转换成二进制小数采用"乘2取整,顺序排列"法。

具体做法是:

  • 用2乘十进制小数,可以得到积
  • 将积的整数部分取出,再用2乘余下的小数部分,又得到一个积
  • 再将积的整数部分取出,如此进行,直到积中的小数部分为零,此时0或1为二进制的最后一位。或者达到所要求的精度为止。
不是所有数都能用二进制表示

0.1的二进制表示中出现了无限循环的情况,也就是(0.1)10 = (0.000110011001100...)2

这种情况,计算机就没办法用二进制精确的表示0.1了。

也就是说,对于像0.1这种数字,我们是没办法将他转换成一个确定的二进制数的。

IEEE 754

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。

其中最常用的就是32位单精度浮点数和64位双精度浮点数。

IEEE并没有解决小数无法精确表示的问题,只是提出了一种使用近似值表示小数的方式,并且引入了精度的概念。

避免精度丢失

在Java中,使用float表示单精度浮点数,double表示双精度浮点数,表示的都是近似值。

所以,在Java代码中,千万不要使用float或者double来进行高精度运算,尤其是金额运算,否则就很容易产生资损问题。

为了解决这样的精度问题,Java中提供了BigDecimal来进行精确运算。

8、为什么不能用BigDecimal的equals方法做等值比较?

因为BigDecimal的equals方法和compareTo并不一样,equals方法会比较两部分内容,分别是值(value)和标度(scale),而对于0.1和0.10这两个数字,他们的值虽然一样,但是精度是不一样的,所以在使用equals比较的时候会返回false。

扩展知识

BigDecimal的比较

因为BigDecimal是对象,所以不能用==来判断两个数字的值是否相等。

BigDecimal的equals原理

在使用BigDecimal的equals方法对1和1.0进行比较的时候,有的时候是true(当使用int、double定义BigDecimal时),有的时候是false(当使用String定义BigDecimal时)。

equals方法和compareTo并不一样,equals方法会比较两部分内容,分别是值(value)和标度(scale)

定义出来的两个BigDecimal对象的标度是不一样的,所以使用equals比较的结果就是false了。

为什么标度不同

首先,最简单的就是BigDecimal(long) 和BigDecimal(int),因为是整数,所以标度就是0

而对于BigDecimal(double) ,当我们使用new BigDecimal(0.1)创建一个BigDecimal 的时候,其实创建出来的值并不是整好等于0.1的,而是0.1000000000000000055511151231257827021181583404541015625 。这是因为double自身表示的只是一个近似值。

那么,无论我们使用new BigDecimal(0.1)还是new BigDecimal(0.10)定义,他的近似值都是0.1000000000000000055511151231257827021181583404541015625这个,那么他的标度就是这个数字的位数,即55。

其他的浮点数也同样的道理。对于new BigDecimal(1.0)这样的形式来说,因为他本质上也是个整数,所以他创建出来的数字的标度就是0。

而对于BigDecimal(String) ,当我们使用new BigDecimal("0.1")创建一个BigDecimal 的时候,其实创建出来的值正好就是等于0.1的。那么他的标度也就是1。

如何比较BigDecimal

BigDecimal中提供了compareTo方法,这个方法就可以只比较两个数字的值,如果两个数相等,则返回0。

9、BigDecimal(double)和BigDecimal(String)有什么区别?

有区别,而且区别很大。

因为double是不精确的,所以使用一个不精确的数字来创建BigDecimal,得到的数字也是不精确的。如0.1这个数字,double只能表示他的近似值。

所以,当我们使用new BigDecimal(0.1)创建一个BigDecimal 的时候,其实创建出来的值并不是正好等于0.1的。

而是0.1000000000000000055511151231257827021181583404541015625。这是因为double自身表示的只是一个近似值。

而对于BigDecimal(String) ,当我们使用new BigDecimal("0.1")创建一个BigDecimal 的时候,其实创建出来的值正好就是等于0.1的。

那么他的标度也就是1

扩展知识

BigDecimal如何精确计数?

如果大家看过BigDecimal的源码,其实可以发现,实际上一个BigDecimal是通过一个"无标度值"和一个"标度"来表示一个数的。

无标度值(Unscaled Value):这是一个整数,表示BigDecimal的实际数值。

标度(Scale):这是一个整数,表示小数点后的位数。

BigDecimal的实际数值计算公式为:unscaledValue × 10^(-scale)。

BigDecimal(double)有什么问题

因为我们知道,double表示的小数是不精确的,如0.1这个数字,double只能表示他的近似值。

所以,当我们使用new BigDecimal(0.1)创建一个BigDecimal 的时候,其实创建出来的值并不是正好等于0.1的。

所以,如果我们在代码中,使用BigDecimal(double) 来创建一个BigDecimal的话,那么是损失了精度的,这是极其严重的。

使用BigDecimal(String)创建

那么,该如何创建一个精确的BigDecimal来表示小数呢,答案是使用String创建。

10、为什么对Java中的负数取绝对值结果不一定是正数?

Integer的取值范围说起,int的取值范围是-2^31 ------ (2^31) - 1

只有当要取绝对值的数字是-2147483648的时候,得到的数字还是个负数。

既然是因为越界了导致最终结果变成负数,那就解决越界的问题就行了,那就是在取绝对值之前,把这个int类型转成long类型,这样就不会出现越界了。

扩展知识

整型的取值范围

整型的这几个类型中,

  • byte:byte用1个字节来存储,范围为-128(-2^7)到127(2^7-1),在变量初始化的时候,byte类型的默认值为0。
  • short:short用2个字节存储,范围为-32,768 (-2^15)到32,767 (2^15-1),在变量初始化的时候,short类型的默认值为0,一般情况下,因为Java本身转型的原因,可以直接写为0。
  • int:int用4个字节存储,范围为-2,147,483,648 (-2^31)到2,147,483,647 (2^31-1),在变量初始化的时候,int类型的默认值为0。
  • long:long用8个字节存储,范围为-9,223,372,036,854,775,808 (-2^63)到9,223,372,036, 854,775,807 (2^63-1),在变量初始化的时候,long类型的默认值为0L或0l,也可直接写为0。
超出范围怎么办

上面说过了,整型中,每个类型都有一定的表示范围,但是,在程序中有些计算会导致超出表示范围,即溢出。如以下代码:

int i = Integer.MAX_VALUE; int j = Integer.MAX_VALUE; int k = i + j; System.out.println("i (" + i + ") + j (" + j + ") = k (" + k + ")");

输出结果:i (2147483647) + j (2147483647) = k (-2)

这就是发生了溢出,溢出的时候并不会抛异常,也没有任何提示。 所以,在程序中,使用同类型的数据进行运算的时候,一定要注意数据溢出的问题。

11、String、StringBuilder和StringBuffer的区别?

String是不可变的,StringBuilder和StringBuffer是可变的。而StringBuffer是线程安全的,而StringBuilder是非线程安全的。

扩展知识

String的不可变性

String是如何实现不可变的:见本篇49

为什么设计成不可变的

String为什么设计成不可变的:见下一题

String的"+"是如何实现的

使用+拼接字符串,其实只是Java提供的一个语法糖,字符串常量在拼接过程中,是将String转成了StringBuilder后,使用其append方法进行处理的。那么也就是说,Java中的+对字符串的拼接,其实现原理是使用StringBuilder.append。

StringBuffer和StringBuilder

最大的区别就是StringBuffer是线程安全的,看一下StringBuffer的`append`方法。该方法使用synchronized进行声明,说明是一个线程安全的方法。而StringBuilder则不是线程安全的。

不要在for循环中使用+拼接字符串

反编译后的代码,在for循环中,每次都是new了一个StringBuilder,然后再把String转成StringBuilder,再进行append。阿里巴巴Java开发手册建议:循环体内,字符串的连接方式,使用 StringBuilderappend 方法进行扩展。而不要使用+

在JDK 9中,引入了StringConcatFactory对+进行了优化:

JDK 9中对字符串的拼接做了什么优化:见本篇第65

12、String为什么设计成不可变的?

主要是从缓存、安全性、线程安全和性能等角度出发的。

缓存

字符串是使用最广泛的数据结构。大量的字符串的创建是非常耗费资源的,所以,Java提供了对字符串的缓存功能,可以大大的节省堆空间。

JVM中专门开辟了一部分空间来存储Java字符串,那就是字符串池。

通过字符串池,两个内容相同的字符串变量,可以从池中指向同一个字符串对象,从而节省了关键的内存资源。

java 复制代码
String s = "abcd";
String s2 = s;

对于这个例子,s和s2都表示"abcd",所以他们会指向字符串池中的同一个字符串对象,但是,之所以可以这么做,主要是因为字符串的不变性。试想一下,如果字符串是可变的,我们一旦修改了s的内容,那必然导致s2的内容也被动的改变了,这显然不是我们想看到的。

安全性

字符串在Java应用程序中广泛用于存储敏感信息,如用户名、密码、连接url、网络连接等。JVM类加载器在加载类的时也广泛地使用它。

因此,保护String类对于提升整个应用程序的安全性至关重要。

当我们在程序中传递一个字符串的时候,如果这个字符串的内容是不可变的,那么我们就可以相信这个字符串中的内容。

但是,如果是可变的,那么这个字符串内容就可能随时都被修改。那么这个字符串内容就完全不可信了。这样整个系统就没有安全性可言了。

线程安全

不可变会自动使字符串成为线程安全的,因为当从多个线程访问它们时,它们不会被更改。

因此,一般来说,不可变对象可以在同时运行的多个线程之间共享。它们也是线程安全的,因为如果线程更改了值,那么将在字符串池中创建一个新的字符串,而不是修改相同的值。因此,字符串对于多线程来说是安全的。

hashcode缓存

由于字符串对象被广泛地用作数据结构,它们也被广泛地用于哈希实现,如HashMap、HashTable、HashSet等。在对这些散列实现进行操作时,经常调用hashCode()方法。

不可变性保证了字符串的值不会改变。因此,hashCode()方法在String类中被重写,以方便缓存,这样在第一次hashCode()调用期间计算和缓存散列,并从那时起返回相同的值。

性能

前面提到了的字符串池、hashcode缓存等,都是提升性能的体现。

因为字符串不可变,所以可以用字符串池缓存,可以大大节省堆内存。而且还可以提前对hashcode进行缓存,更加高效

由于字符串是应用最广泛的数据结构,提高字符串的性能对提高整个应用程序的总体性能有相当大的影响。

13、String str=new String("hollis")创建了几个对象?

创建的对象数应该是1个或者2个。

首先要清楚什么是对象?

Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的,在HotSpot虚拟机中,存储的形式就是oop-klass model,即Java对象模型。我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了两部分信息,对象头以及元数据。对象头中有一些运行时数据,其中就包括和多线程相关的锁的信息。元数据其实维护的是指针,指向的是对象所属的类的instanceKlass。

这才叫对象。其他的,一概都不叫对象。

那么不管怎么样,一次new的过程,都会在堆上创建一个对象,那么就是起码有一个对象了。至于另外一个对象,到底有没有要看具体情况了。

另外这一个对象就是常量池中的字符串常量,这个字符串其实是类编译阶段就进到Class常量池的,然后在运行期,字符串常量在第一次被调用(准确的说是ldc指令)的时候,进行解析并在字符串池中创建对应的String实例的。

在运行时常量池中,也并不是会立刻被解析成对象,而是会先以JVM_CONSTANT_UnresolveString_info的形式驻留在常量池。在后面,该引用第一次被LDC指令执行到的时候,就尝试在堆上创建字符串对象,并将对象的引用驻留在字符串常量池中。

通过看上面的过程,你也能发现,这个过程的触发条件是我们没办法决定的,问题的题干中也没提到。有可能执行这段代码的时候是第一次LDC指令执行,也许在前面就执行过了。

所以,如果是第一次执行,那么就是会同时创建两个对象。一个字符串常量引用指向的对象,一个我们new出来的对象。

如果不是第一次执行,那么就只会创建我们自己new出来的对象。

扩展知识

字面量和运行时常量池

JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池。

在JVM运行时区域的方法区中,有一块区域是运行时常量池,主要用来存储编译期 生成的各种字面量符号引用

java 复制代码
   #16 = Utf8               s
   #21 = Utf8               Hollis
   #22 = Utf8               StringDemo

上面几个常量中,s就是前面提到的符号引用 ,而Hollis就是前面提到的字面量 。而Class文件中的常量池部分的内容,会在运行期被运行时常量池加载进去。关于字面量,详情参考Java SE Specifications

intern

编译期 生成的各种字面量符号引用 是运行时常量池中比较重要的一部分来源,但是并不是全部。那么还有一种情况,可以在运行期向运行时常量池中增加常量。那就是Stringintern方法。

当一个String实例调用intern()方法时,Java查找常量池中是否有相同Unicode的字符串常量,如果有,则返回其的引用,如果没有,则在常量池中增加一个Unicode等于str的字符串并返回它的引用;

intern()有两个作用,第一个是将字符串字面量放入常量池(如果池没有的话),第二个是返回这个常量的引用。

String中的intern原理是什么:本篇51

14、String a = "ab"; String b = "a" + "b"; a == b 吗?

在Java中,对于字符串使用==比较的是字符串对象的引用地址是否相同。

因为"ab"和"a"、"b"都是由字面量(""包裹的内容)组成的字符串,在编译之后,会把用"+"拼接的字面量直接合在一起。因此他们的最终都是"ab",而字面值最终在字符串池只有一份,所以a == b的结果为true,因为它们指向的是同一个字符串对象。

扩展知识

字面量

在计算机科学中,字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示,诸如:整数、浮点数以及字符串;而有很多也对布尔类型和字符类型的值也支持字面量表示;还有一些甚至对枚举类型的元素以及像数组、记录和对象等复合类型的值也支持字面量表示法。

以上是关于计算机科学中关于字面量的解释,并不是很容易理解。说简单点,字面量就是指由字母、数字等构成的字符串或者数值。

字面量只可以右值出现,所谓右值是指等号右边的值,如:int a=123这里的a为左值,123为右值。在这个例子中123就是字面量。

JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池。

在JVM中,有一块区域是运行时常量池,主要用来存储编译期生成的各种字面量符号引用

运行时常量池和字符串常量池的关系是什么:JVM篇38

15、String有长度限制吗?是多少?

有,编译期和运行期不一样。

编译期需要用CONSTANT_Utf8_info 结构用于表示字符串常量的值,而这个结构是有长度限制,他的限制是65535。

运行期,String的length参数是Int类型的,那么也就是说,String定义的时候,最大支持的长度就是int的最大范围值。根据Integer类的定义,java.lang.Integer#MAX_VALUE的最大值是2^31 - 1;

扩展知识

常量池限制

我们知道,javac是将Java文件编译成class文件的一个命令,那么在Class文件生成过程中,就需要遵守一定的格式。

根据《Java虚拟机规范》中第4.4章节常量池的定义,Class文件中常量池的格式规定了,其字符串常量的长度不能超过65535。

运行期限制

上面提到的这种String长度的限制是编译期的限制,也就是使用String s= "";这种字面值方式定义的时候才会有的限制。

那么。String在运行期有没有限制呢,答案是有的。

String类中有很多重载的构造函数,其中有几个是支持用户传入length来执行长度的:

java 复制代码
public String(byte bytes[], int offset, int length)

可以看到,这里面的参数length是使用int类型定义的,那么也就是说,String定义的时候,最大支持的长度就是int的最大范围值。

根据Integer类的定义,java.lang.Integer#MAX_VALUE的最大值是2^31 - 1;这个值约等于4G,在运行期,如果String的长度超过这个范围,就可能会抛出异常。(在jdk 1.9之前)

很多人会有疑惑,编译的时候最大长度都要求小于65535了,运行期怎么会出现大于65535的情况呢。这其实很常见,如以下代码:

java 复制代码
String s = "";
for (int i = 0; i <100000 ; i++) {
    s+="i";
}

得到的字符串长度就有10万,另外我之前在实际应用中遇到过这个问题。

之前一次系统对接,需要传输高清图片,约定的传输方式是对方将图片转成BASE64编码,我们接收到之后再转成图片。

在将BASE64编码后的内容赋值给字符串的时候就抛了异常。

后来为了解决这个问题,不再传输图片的BASE64编码内容了,而是先把文件上传到OSS或者FTP中,然后直接传递文件地址。

16、RPC接口返回中,使用基本类型还是包装类?

使用包装类,不要使用基本类型,比如某个字段表示费率的Float rate,在接口中返回时,如果出现接口异常的情况,那么可能会返回默认值,float的话返回的是0.0,而Float返回的是null。

在接口中,为了避免发生歧义,建议使用对象,因为他默认值是null,当看到null的时候,我们明确的知道他是出错了,但是看到0.0的时候,你不知道是因为出错返回的0.0,还是就是不出错真的返回了0.0,虽然可以用其他的字段如错误码或者getSuccess判断,但是还是尽量减少歧义的可能。

知识扩展

在接口定义的时候,如何定义一个字段表示是否成功?

以下四种:

java 复制代码
boolean success
Boolean success
boolean isSuccess
Boolean isSuccess

建议使用第2种:

首先,作为接口的返回对象的参数,这个字段不应该有不确定的值,而Boolean类型的默认值是null,而boolean的默认值是false,所以,当拿到一个false的时候,你就不知道是真的false了,还是因为出了问题而默认返回的false。

其他,关于参数名称,要使用success还是isSuccess,这一点在阿里巴巴Java开发手册中有明确规定和解释:

【强制】 POJO 类中的任何布尔类型的变量,都不要加 is,否则部分框架解析会引起序列化错误。

反例: 定义为基本数据类型 boolean isSuccess;的属性,它的方法也是 isSuccess(), RPC

框架在反向解析的时候, " 以为" 对应的属性名称是 success,导致属性获取不到,进而抛出

异常。

17、常见的字符编码有哪些?有什么区别?

就像电报只能发出"滴"和"答"声一样,计算机只认识0和1两种字符,但是,人类的文字是多种多样的,如何把人类的文字转换成计算机认识的01字符呢,这个过程同样需要通过字符编码

字符编码(Character encoding)是一套法则,使用该法则能够对自然语言的字符的一个集合(如字母表或音节表),与其他东西的一个集合(如号码或电脉冲)进行配对。

和摩尔斯电码功能类似,上个世纪60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定,这被称为 ASCII 码,一直沿用至今。

由于ASCII只有128个字符,虽然对于英文字符都可以表示了,但是世界上还有很多其他的文字他是没办法表示的,所以需要一种更加全面的字符编码。

于是又出现了Unicode字符集 (常见的Unicode Transformation Format有:UTF-7, UTF-7.5, UTF-8 ,UTF-16, 以及 UTF-32),除此之外还有一些常用的中文编码有GBKGB2312GB18030等。

扩展知识

Unicode和UTF-8有啥关系?

Unicode是一套通用的字符集,包含世界上的大部分文字,也就是说,Unicode是可以表示中文的。

但是,Unicode虽然统一了全世界字符的编码,但没有规定如何存储。

因为如果Unicode统一规定,每个符号就要用三个或四个字节表示,因为字符太多,只能用这么多字节才能表示完全。一旦这么规定,那么每个英文字母前都必然有二到三个字节是0,因为所有英文字母在ASCII中都有,都可以用一个字节表示,剩余字节位置就要补充0。如果这样,文本文件的大小会因此大出二三倍,这对于存储来说是极大的浪费。

为了解决这个问题,就出现了一些中间格式的字符集,他们被称为通用转换格式,**即UTF(Unicode Transformation Format)。**常见的UTF格式有:UTF-7, UTF-7.5, UTF-8,UTF-16, 以及 UTF-32。

UTF-8 使用一至四个字节为每个字符编码

UTF-16 使用二或四个字节为每个字符编码

UTF-32 使用四个字节为每个字符编码

所以我们可以说,UTF-8、UTF-16等都是 Unicode 的一种实现方式。

有了UTF-8,为什么要出现GBK

因为UTF-8是Unicode的一种实现,所以他包含了世界上的所有文字的编码,他采用的是1-4字节进行编码。

对于那些排在前面优先纳入的文字,可能就优先使用1字节、2字节存储了,对于后纳入的文字,就要使用3字节或者4字节存储了。

正是因为UTF-8太全了,所以那些晚一些纳入的字符,在UTF-8中的存储所占的字节数可能就会多一些,那他的存储空间要求就会很大。

对于常用的汉字,在UTF-8中采用3字节进行编码,但是如果有一种只包含中文和ASCII的编码的话,就不需要使用3个字节,可能2个字节就够了。

对于大部分网站来说,基本都是只服务一个国家或者地区的,比如一个中国的网站,一般会出现简体字和繁体字以及一些英文字符,很少会出现日语或者韩文的。

也是出于这样的考虑,中国国家标准总局于1981年制定并实施了 GB 2312-80 编码,即中华人民共和国国家标准简体中文字符集。后来厂商微软利用GB 2312-80未使用的编码空间,收录GB 13000.1-93全部字符制定了GBK编码。

有了标准中文字符集,如果是一个纯中文网站,就可以可以采用这种编码方式,这样可以大大节省一些存储空间的。

为什么会出现乱码

文件里面的内容归根到底都是有0101组成的,至于0101的二进制码如何转成人们可以理解的字符串,则是需要通过规定好的字符编码标准进行转换才可以。

我们把一串中文字符通过UTF-8进行编码传输给别人,别人拿到这串文字之后,通过GBK进行解码,得到的内容就会是"锟届瀿锟斤拷雮傡锟斤拷直锟斤拷锟",这就是乱码。

18、说几个常见的语法糖?

语法糖(Syntactic sugar),指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。

虽然Java中有很多语法糖,但是Java虚拟机并不支持这些语法糖,所以这些语法糖在编译阶段就会被还原成简单的基础语法结构,这样才能被虚拟机识别,这个过程就是解语法糖。

如果看过Java虚拟机的源码,就会发现在编译过程中有一个重要的步骤就是调用desugar(),这个方法就是负责解语法糖的实现。

常见的语法糖有 switch支持枚举及字符串、泛型、条件编译、断言、可变参数、自动装箱/拆箱、枚举、内部类、增强for循环、try-with-resources语句、lambda表达式等。

知识扩展

糖块一、 switch 支持 String 与枚举

Java中的switch自身原本就支持基本类型。比如intchar等。对于int类型,直接进行数值的比较。对于char类型则是比较其ascii码。所以,对于编译器来说,switch中其实只能使用整型,任何类型的比较都要转换成整型。比如byteshortchar(asckii码是整型)以及int

字符串的switch是通过 **equals()** **hashCode()**方法来实现的。 还好hashCode()方法返回的是int,而不是long

进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行switch或者使用纯整数常量,但这也不是很差。

糖块二、 泛型

不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specializationCode sharing。C++和C#是使用Code specialization的处理机制,而Java使用的是Code sharing的机制。

Code sharing方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(type erasure)实现的。

也就是说,对于Java虚拟机来说,他根本不认识 Map<String, String> map****这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。

类型擦除的主要过程如下: 1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。

虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的 Class****类对象。比如并不存在 List.class****或是 List.class**,而只有** List.class**。**

糖块三、 自动装箱与拆箱

自动装箱就是Java自动将原始类型值转换成对应的对象,比如将int的变量转换成Integer对象,这个过程叫做装箱,反之将Integer对象转换成int类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型byte, short, char, int, long, float, double 和 boolean 对应的封装类为Byte, Short, Character, Integer, Long, Float, Double, Boolean。装箱过程是通过调用包装器的valueOf方法实现的,而拆箱过程是通过调用包装器的 xxxValue方法实现的。

糖块四 、 方法变长参数

可变参数(variable arguments)是在Java 1.5中引入的一个特性。它允许一个方法把任意数量的值作为参数。可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。

糖块五 、 枚举

在Java中,枚举是一种特殊的数据类型,用于表示有限的一组常量。枚举常量是在枚举类型中定义的,每个常量都是该类型的一个实例。Java中的枚举类型是一种安全而优雅的方式来表示有限的一组值。那么枚举类型到底是什么类呢?是enum吗?答案很明显不是,enum就和class一样,只是一个关键字,他并不是一个类,public final class T extends Enum,说明,该类是继承了Enum类的,同时final关键字告诉我们,这个类也是不能被继承的。当我们使用 enum****来定义一个枚举类型的时候,编译器会自动帮我们创建一个 final****类型的类继承 Enum****类,所以枚举类型不能被继承。

糖块六 、 内部类

内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。

内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念, outer.java****里面定义了一个内部类 inner**,一旦编译成功,就会生成两个完全不同的** .class****文件了,分别是 outer.class****和 outer$inner.class**。**

糖块七 、条件编译

---般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。

Java语法的条件编译,是通过判断条件为常量的if语句实现的。其原理也是Java语言的语法糖。根据if判断条件的真假,编译器直接把分支为false的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个Java类的结构或者类的属性上进行条件编译,这与C/C++的条件编译相比,确实更有局限性。在Java语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。

糖块八 、 断言

在Java中,assert关键字是从JAVA SE 1.4 引入的,为了避免和老版本的Java代码中使用了assert关键字导致错误,Java在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions-ea来开启。

其实断言的底层实现就是if语言,如果断言结果为true,则什么都不做,程序继续执行,如果断言结果为false,则程序抛出AssertError来打断程序的执行。 -enableassertions会设置$assertionsDisabled字段的值。

糖块九 、 数值字面量

在java 7中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。反编译后就是把_删除了。也就是说 编译器并不认识在数字字面量中的 _****,需要在编译阶段把他去掉。

糖块十 、 for-each

增强for循环(for-each)相信大家都不陌生,日常开发经常会用到的,他会比for循环要少写很多代码,那么这个语法糖背后是如何实现的呢?代码很简单,for-each的实现原理其实就是使用了普通的for循环和迭代器。

糖块十一 、 try-with-resource

Java里,对于文件操作IO流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过close方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。

关闭资源的常用方式就是在finally块里是释放,即调用close方法。

从Java 7开始,jdk提供了一种更好的方式关闭资源,使用try-with-resources语句,改写一下上面的代码,效果如下:

java 复制代码
public static void main(String... args) {
    try (BufferedReader br = new BufferedReader(new FileReader("d:\\ hollischuang.xml"))) {
        String line;
        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }
    } catch (IOException e) {
        // handle exception
    }
}

其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。

糖块十二、Lambda表达式

Lambda表达式是如何实现的:见下一题

总结

前面介绍了12种Java中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成JVM认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。

有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避免过渡使用。使用之前最好了解下原理,避免掉坑。

19、Lambda表达式是如何实现的

Labmda表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个JVM底层提供的lambda相关api,在编译阶段,编译器会把lambda表达式进行解糖,转换成调用内部api的方式。。

20、什么是泛型?有什么好处?

Java泛型(generics) 是JDK 5中引入的一个新特性,允许在定义类和接口的时候使用类型参数(type parameter)。声明的类型参数在使用时用具体的类型来替换。泛型最主要的应用是在JDK 5中的新集合类框架中。

泛型的好处有两个:

  1. 方便:可以提高代码的复用性。以List接口为例,我们可以将String、Integer等类型放入List中,如不用泛型,存放String类型要写一个List接口,存放Integer要写另外一个List接口,泛型可以很好的解决这个问题
  2. 安全:在泛型出之前,通过Object实现的类型转换需要在运行时检查,如果类型转换出错,程序直接GG,可能会带来毁灭性打击。而泛型的作用就是在编译时做类型检查,这无疑增加程序的安全性

知识扩展

泛型是如何实现的

Java中的泛型通过类型擦除的方式来实现,通俗点理解,就是通过语法糖的形式,在.java->.class转换的阶段,将List<String>擦除调转为List的手段。换句话说,Java的泛型只在编译期,Jvm是不会感知到泛型的。

什么是类型擦除:见下一题

类型擦除的缺点有哪些?
  1. 泛型不可以重载
  2. 泛型异常类不可以多次catch
  3. 泛型类中的静态变量也只有一份,不会有多份
对泛型通配符的理解

泛型中上下界限定符extends和super有什么区别:见本篇23

List<?>, List<Object>, List之间的区别
  1. List<?> 是一个未知类型的List,而List<Object> 其实是任意类型的List。可以把List<String>, List<Integer>赋值给List<?>,却不能把List<String>赋值给 List<Object>
  2. 可以把任何带参数的类型传递给原始类型List,但却不能把List<String>赋值给List<Object>,因为会产生编译错误(不支持协变)
  3. List<?>由于不确定列表中元素的具体类型,因此只能从这种列表中读取数据,而不能往里面添加除了 null 之外的任何元素。
在泛型为Integer的ArrayList中存放一个String类型的对象

通过反射可以实现:

java 复制代码
public void test() throws Exception {
    ArrayList<Integer> list = new ArrayList<Integer>();
    Method method = list.getClass().getMethod("add", Object.class);
    method.invoke(list, "Java反射机制实例");
    System.out.println(list.get(0));
}
对数组协变和泛型非协变的理解

所谓协变,可以简单理解为因为Object是String的父类,所以Object\[\]同样是String\[\]的父类,这种情况Java是允许的;但是对于泛型来说,List<Object>和List<String>半毛钱关系都没有。

但是,为什么泛型不允许协变,而数组允许协变呢?原因有二:

  1. 因为数组设计之初没有泛型,为了兼容考虑,如Arrays.equals(Object[], Object[])方法,是时代无奈的产物
  2. 数组也属于对象,它记录了引用实际的类型,在放入数组的时候,如果类型不一样就会报错,而不是等到拿出来的时候才发现问题,相对来说安全一点

21、

JVM篇

1、Java是如何实现的平台无关?

2、Java是编译型还是解释型?

3、什么是编译和反编译?

4、简单介绍一下JIT优化技术?

相关推荐
晚安日记wanna1 小时前
大文件分片上传的五层追问从 Blob 切片到秒传与弱网容错
前端·面试
艺杯羹1 小时前
AI编程时代软件工程怎么学:从底层思维认知到驱动智能体的架构跃迁
java·人工智能·ai·架构·软件工程·ai编程
土司大王1 小时前
LeetCode hot100——51.N 皇后:Java 回溯模板、列与对角线剪枝、O(n!) 复杂度分析
java·leetcode·剪枝
重生之小比特1 小时前
【Java SE】IDEA 调试 Debug 案例完整分析
java·ide·intellij-idea
晴天161 小时前
JavaScript 与 Java 垃圾回收机制对比
java·开发语言·javascript
晚安日记wanna1 小时前
只会答加索引MySQL 调优还能聊这 7 个点
数据库·面试
xiaohua10091 小时前
JVM内存优化-jemalloc
java·jvm
吃饱了得干活1 小时前
Redisson 进阶实战:看门狗机制与分布式锁完全指南
java·redis·后端
淘源码d2 小时前
基于Java开发的上门家政系统源码,采用SpringBoot+MySQL+UniApp+Vue3技术栈,构建三端一体化平台
java·源码·上门家政·家政系统·同城家政·源码部署