声明:本文仅为个人学习分析笔记,非商用;部分内容参考/改编自 JavaGuide相关文章,不代表原创教程。
基本数据类型
1.为什么正数范围要减1?
可以看到,像byte、short、int、long能表示的最大正数都减1了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0表示正数,1表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为1。如果我们再加1,就会导致溢出,变成一个负数。
2.基本数据类型和包装类的区别
存储方式:基本数据类型的局部变量存放在Java虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被static修饰)存放在Java虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
占用空间:相比于包装类型(对象类型),基本数据类型占用的空间往往非常小。
默认值:成员变量包装类型不赋值就是null,而基本类型有默认值且不是null。
比较方式:对于基本数据类型来说,==比较的是值。对于包装数据类型来说,==比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用equals()方法。
3.包装类的缓存机制
Java基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
整型:Byte,Short,Integer,Long这4种包装类默认创建了数值**[-128,127]的相应类型的缓存数据,Character创建了数值在[0,127]**范围的缓存数据,Boolean直接返回TRUEorFALSE。
对于Integer,可以通过JVM参数-XX:AutoBoxCacheMax=<size>修改缓存上限,但不能修改下限-128。实际使用时,并不建议设置过大的值,避免浪费内存,甚至是OOM。
对于Byte,Short,Long,Character没有类似-XX:AutoBoxCacheMax参数可以修改,因此缓存范围是固定的,无法通过JVM参数调整。Boolean则直接返回预定义的TRUE和FALSE实例,没有缓存范围的概念。
如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
两种浮点数类型的包装类Float,Double并没有实现缓存机制。
4.为什么浮点数有精度丢失问题
计算机是二进制的,计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就解释了为什么浮点数没有办法用二进制精确表示。
5.如何解决浮点数精度丢失问题
BigDecimal可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过BigDecimal来做的。
6.超过long整型的数据该怎么表示
BigInteger内部使用int[]数组来存储任意大小的整形数据。
相对于常规整数类型的运算来说,BigInteger运算的效率会相对较低。
变量
语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被public,private,static等修饰符所修饰,而局部变量不能被访问控制修饰符及static所修饰;但是,成员变量和局部变量都能被final所修饰。
存储方式:从变量在内存中的存储方式来看,如果成员变量是使用static修饰的,那么这个成员变量是属于类的,如果没有使用static修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被final修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
1.为什么成员变量有默认值
核心原因是为了保证对象状态的安全和可预测性。
成员变量和局部变量在这个规则上不同,主要是因为它们的生命周期不一样,导致了编译器对它们的“控制力”也不同。
-
局部变量只活在一个方法里,编译器能清楚地看到它是否在使用前被赋值,所以编译器会强制你必须手动赋值,否则就报错。
-
成员变量是跟着对象走的,它的值可能在构造函数里赋,也可能在后面的某个setter方法里赋。编译器在编译时无法预测它到底什么时候会被赋值。
并且,如果一个变量没有被初始化,它的内存里存放的就是“垃圾值”——之前那块内存遗留下的任意数据。如果程序读取并使用了这个垃圾值,就会产生完全不可预测的结果,比如一个数字变成了随机数,一个对象引用变成了非法地址,这会直接导致程序崩溃或出现诡异的bug。
为了避免你拿到一个含有“垃圾值”的危险对象,Java干脆为所有成员变量提供了一个安全的默认值(如null或0),作为一种安全兜底机制。
2.静态变量有什么用
静态变量也就是被static关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的
通常情况下,静态变量会被final关键字修饰成为常量。
3.字符型常量和字符串型常量有什么区别
形式:字符常量是单引号引起的一个字符,字符串常量是双引号引起的0个或若干个字符。
含义:字符常量相当于一个整型值(ASCII值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置)。
占内存大小:字符常量只占2个字节;字符串常量占若干个字节。
方法
1.静态方法为什么不能调用非静态成员
静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
2.静态方法和实例方法有何不同
1、调用方式
在外部调用静态方法时,可以使用类名.方法名的方式,也可以使用对象.方法名的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。
不过,需要注意的是一般不建议使用对象.方法名的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。
因此,一般建议使用类名.方法名的方式来调用静态方法。
2、访问类成员是否存在限制
静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。
3.重载与重写有何区别
(1)重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理
发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
(2)重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法
发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。
方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
如果父类方法访问修饰符为private/final/static则子类就不能重写该方法,但是被static修饰的方法能够被再次声明。
构造方法无法被重写
重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。
关于重写的返回值类型这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是void和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。
4.什么是可变长参数
从Java5开始,Java支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受0个或者多个参数。
可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。
**遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?**答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。
Java的可变参数编译后实际会被转换成一个数组
面向对象基础
1.面向对象和面向过程区别
面向过程编程(Procedural-OrientedProgramming,POP)和面向对象编程(Object-OrientedProgramming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:
-
面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
-
面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
相比较于POP,OOP开发的程序一般具有下面这些优点:
-
易维护:由于良好的结构和封装性,OOP程序通常更容易维护。
-
易复用:通过继承和多态,OOP设计使得代码更具复用性,方便扩展功能。
-
易扩展:模块化设计使得系统扩展变得更加容易和灵活。
POP的编程方式通常更为简单和直接,适合处理一些较简单的任务。
POP和OOP的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。
2.创建一个对象用什么运算符,实例对象与对象引用有何不同
new运算符,new创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
-
一个对象引用可以指向0个或1个对象(一根绳子可以不系气球,也可以系一个气球);
-
一个对象可以有n个引用指向它(可以用n条绳子系住一个气球)。
3.对象的相等和引用的相等的区别
-
对象的相等一般比较的是内存中存放的内容是否相等。
-
引用相等一般比较的是他们指向的内存地址是否相等。
4.如果一个类没有声明构造方法,能正常运行吗
构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。
如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java就不会添加默认的无参数的构造方法了。
我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。
5.构造方法有哪些特点,能否被重写override
构造方法具有以下特点:
-
名称与类名相同:构造方法的名称必须与类名完全一致。
-
没有返回值:构造方法没有返回类型,且不能使用void声明。
-
自动执行:在生成类的对象时,构造方法会自动执行,无需显式调用。
构造方法不能被重写(override),但可以被重载(overload)。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。
6.面向对象三大特征-封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。
7.面向对象三大特征-继承
不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。
继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间,提高我们的开发效率。
关于继承:
子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
子类可以用自己的方式实现父类的方法。
被final关键字修饰的类不能被继承,final修饰的方法不能被重写,final修饰的变量是基本数据类型则值不能改变,final修饰的变量是引用类型则不能再指向其他对象。
继承中的构造方法:
子类不能继承父类的构造方法,但必须调用它。
-
默认调用:子类的构造方法第一行,默认隐藏了一句super();。这意味着在创建子类对象之前,必须先完成父类的初始化。
-
显式调用:如果父类没有无参构造方法(手动定义了带参构造),子类必须在自己的构造方法第一行显式使用super(参数);,否则编译报错。
-
加载顺序:
-
父类静态代码块(仅一次)
-
子类静态代码块(仅一次)
-
父类构造代码块+父类构造方法
-
子类构造代码块+子类构造方法
-
8.面向对象三大特征-多态
多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
多态的特点:
-
对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
-
引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
-
多态不能调用“只在子类存在但在父类不存在”的方法;
-
如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。
多态中成员访问的特点:
成员变量:编译看左边,运行也看左边。(父类有什么就用什么,子类同名变量会被隐藏),例如:
class Parent {
int num = 10;
}
class Child extends Parent {
int num = 20; // 隐藏父类的 num
}
public class Test {
public static void main(String[] args) {
Parent obj = new Child(); // 父类引用
System.out.println(obj.num); // 输出 10
}
}
成员方法:编译看左边,运行看右边。(因为方法被子类重写了,所以执行子类的逻辑)例如:
class Parent {
void show() {
System.out.println("Parent Show");
}
}
class Child extends Parent {
@Override
void show() {
System.out.println("Child Show");
}
void secret() {
System.out.println("Child Secret");
}
}
public class Test {
public static void main(String[] args) {
Parent obj = new Child();
obj.show(); // 运行时调用,输出 "Child Show"
// obj.secret(); // 编译错误:父类引用不能调用子类特有方法
}
}
9.接口和抽象类有什么共同点和区别
共同点:
-
实例化:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。
-
抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。
区别:
设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
继承和实现:一个类只能继承一个类(包括抽象类),因为Java不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。
成员变量:接口中的成员变量只能是publicstaticfinal类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private,protected,public),可以在子类中被重新定义或赋值。
方法:
-
Java8之前,接口中的方法默认是publicabstract,也就是只能有方法声明。自Java8起,可以在接口中定义default(默认)方法和static(静态)方法。自Java9起,接口可以包含private方法。
-
抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。
在Java8及以上版本中,接口引入了新的方法类型:default方法、static方法和private方法。这些方法让接口的使用更加灵活。
Java8提供的default方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。
Java8提供的static方法无法在实现类中被覆盖,只能通过接口名直接调用(MyInterface.staticMethod()),类似于类中的静态方法。static方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。
Java9允许在接口中使用private方法。private方法可以用于在接口内部共享代码,不对外暴露。
public interface MyInterface {
// default 方法
default void defaultMethod() {
commonMethod();
instanceCommonMethod();
}
// 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.");
}
}
10.深拷贝和浅拷贝
浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
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("HWX"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());
深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
Object
1.Object类的常见方法
Object类是一个特殊的类,是所有类的父类,主要提供了以下11个方法:
/**
*native方法,用于返回当前运行时对象的Class对象,使用了final关键字修饰,故不允许子类重写。
*/
publicfinalnativeClass<?>getClass()
/**
*native方法,用于返回对象的哈希码,主要使用在哈希表中,比如JDK中的HashMap。
*/
publicnativeinthashCode()
/**
*用于比较2个对象的内存地址是否相等,String类对该方法进行了重写以用于比较字符串的值是否相等。
*/
publicbooleanequals(Objectobj)
/**
*native方法,用于创建并返回当前对象的一份拷贝。
*/
protectednativeObjectclone()throwsCloneNotSupportedException
/**
*返回类的名字实例的哈希码的16进制的字符串。建议Object所有的子类都重写这个方法。
*/
publicStringtoString()
/**
*native方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
publicfinalnativevoidnotify()
/**
*native方法,并且不能重写。跟notify一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
publicfinalnativevoidnotifyAll()
/**
*native方法,并且不能重写。暂停线程的执行。注意:sleep方法没有释放锁,而wait方法释放了锁,timeout是等待时间。
*/
publicfinalnativevoidwait(longtimeout)throwsInterruptedException
/**
*多了nanos参数,这个参数表示额外时间(以纳秒为单位,范围是0-999999)。所以超时的时间还需要加上nanos纳秒。。
*/
publicfinalvoidwait(longtimeout,intnanos)throwsInterruptedException
/**
*跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
publicfinalvoidwait()throwsInterruptedException
/**
*实例被垃圾回收器回收的时候触发的操作
*/
protectedvoidfinalize()throwsThrowable{}
2.==和equals()的区别
**==**对于基本类型和引用类型的作用效果是不同的:
-
对于基本数据类型来说,==比较的是值。
-
对于引用数据类型来说,==比较的是对象的内存地址
因为Java只有值传递,所以,对于==来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。
**equals()**不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。
equals()方法存在两种使用情况:
-
类没有重写equals()方法:通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是Object类equals()方法。
-
类重写了equals()方法:一般我们都重写equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回true(即,认为这两个对象相等)。
String中的equals方法是被重写过的,因为Object的equals方法是比较的对象的内存地址,而String的equals方法比较的是对象的值。
当使用字符串字面量创建String类型的对象(如Stringaa="ab")时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用;如果没有,就在常量池中创建一个String对象并赋给当前引用。但当使用new关键字创建对象(如Stringa=newString("ab"))时,虚拟机总是会在堆内存中创建一个新的对象并使用常量池中的值(如果没有,会先在字符串常量池中创建字符串对象"ab")进行初始化,然后赋给当前引用。
3.hashCode()方法有什么用
hashCode()的作用是获取哈希码(int整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表(HashSet)中的索引位置。
hashCode()定义在JDK的Object类中,这就意味着Java中的任何类都包含有hashCode()函数。另外需要注意的是:Object的hashCode()方法是本地方法,也就是用C语言或C++实现的。
4.为什么要有hashCode
以“HashSet如何检查重复”为例子来说明为什么要有hashCode
当我们把对象加入HashSet时,HashSet会先调用对象的hashCode()方法,得到一个“哈希值”,并通过内部散列函数对这个哈希值再做一次简单的转换(比如取余),决定这条数据应该放进底层数组的哪一个桶(bucket,对应到底层数组的某个位置):
-
如果该桶当前是空的,就直接将对象对应的节点插入到这个桶中。
-
如果该桶中已经有其他元素,HashSet会在这个桶对应的链表或红黑树中逐个比较:
-
对于哈希值不同的节点,直接跳过;
-
对于哈希值相同的节点,则会进一步调用equals()方法来检查这两个对象是否“相等”:
–如果equals()返回true,说明集合中已经存在与当前对象等价的元素,HashSet就不会再次加入它;
–如果返回false,则认为是新元素,会将该对象作为一个新节点加入到同一个桶的链表或红黑树中。
-
通过先利用hashCode()将候选范围缩小到同一个桶内,再在桶内少量元素上调用equals()做精确判断,HashSet大大减少了equals()的调用次数,从而提高了查找和插入的执行效率。核心矛盾:equals()虽准,但太慢
5.为什么不只采用hashCode,还要一并采用equals
有了hashCode()之后,判断元素是否在对应容器中的效率会更高,但两个对象的hashCode值相等并不代表两个对象就相等。还需要使用equals()来判断是否真的相同。
6.为什么两个对象有相同的hashCode值,它们也不一定是相等的?
因为hashCode()所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞就是指不同的对象得到相同的hashCode)。
总结下来就是:
-
如果两个对象的hashCode值相等,那这两个对象不一定相等(哈希碰撞)。
-
如果两个对象的hashCode值相等并且equals()方法也返回true,我们才认为这两个对象相等。
-
如果两个对象的hashCode值不相等,我们就可以直接认为这两个对象不相等。
7.为什么重写equals()时必须重写hashCode()方法?
因为两者是配合工作的,Java官方对它们有严格的规定(如果你重写了其中一个,必须重写另一个):
-
如果两个对象调用equals()返回true,那么它们的hashCode必须相等。
- 原因:如果equals相等但hashCode不同,它们会被分到不同的“桶”里,导致集合中出现重复元素。
-
如果两个对象的hashCode相等,它们不一定equals。
- 情况:这就是“哈希冲突”。就像不同的人可能住在同一栋楼,你需要进门(调用equals)才能确认到底是谁。
-
如果两个对象的hashCode不等,那么它们一定不equals。
- 结果:直接判定为不同对象,连equals都不用调了,省时省力。
String
1.String、StringBuffer、StringBuilder的区别?
(1)可变性
String是不可变的(后面会详细分析原因)。
StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,不过没有使用final和private关键字修饰,最关键的是这个AbstractStringBuilder类还提供了很多修改字符串的方法比如append方法。
(2)线程安全性
String中的对象是不可变的,也就可以理解为常量,线程安全。
AbstractStringBuilder是StringBuilder与StringBuffer的公共父类,定义了一些字符串的基本操作,如expandCapacity、append、insert、indexOf等公共方法。StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder并没有对方法进行加同步锁,所以是非线程安全的。
(3)性能
每次对String类型进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String对象。StringBuffer每次都会对StringBuffer对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用StringBuilder相比使用StringBuffer仅能获得10%~15%左右的性能提升,但却要冒多线程不安全的风险。
2.String为什么是不可变的?
保存字符串的数组被final修饰且为私有的,并且String类没有提供/暴露修改这个字符串的方法。
String类被final修饰导致其不能被继承,进而避免了子类破坏String不可变。
3.字符串拼接用“+”还是StringBuilder?
Java语言本身并不支持运算符重载,“+”和“+=”是专门为String类重载过的运算符,也是Java中仅有的两个重载过的运算符。
字符串对象通过“+”的字符串拼接方式,实际上是通过StringBuilder调用append()方法实现的,拼接完成之后调用toString()得到一个String对象。
不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个StringBuilder以复用,每循环一次就会创建一个StringBuilder对象,会导致创建过多的StringBuilder对象。
如果直接使用StringBuilder对象进行字符串拼接的话,就不会存在这个问题了。
4.String#equals()和Object#equals()有何区别?
String中的equals方法是被重写过的,比较的是String字符串的值是否相等。Object的equals方法是比较的对象的内存地址。
5.字符串常量池的作用
字符串常量池是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
6.Strings1=newString("abc");这句话创建了几个字符串对象?
答案:会创建1或2个字符串对象。
-
字符串常量池中不存在"abc":会创建2个字符串对象。一个在字符串常量池中,由ldc指令触发创建。一个在堆中(普通的String对象),由newString()创建,并使用常量池中的"abc"进行初始化。
-
字符串常量池中已存在"abc":会创建1个字符串对象。该对象在堆中(普通的String对象),由newString()创建,并使用常量池中的"abc"进行初始化。
ldc(loadconstant)指令的确是从常量池中加载各种类型的常量,包括字符串常量、整数常量、浮点数常量,甚至类引用等。对于字符串常量,ldc指令的行为如下:
-
从常量池加载字符串:ldc首先检查字符串常量池中是否已经有内容相同的字符串对象。
-
复用已有字符串对象:如果字符串常量池中已经存在内容相同的字符串对象,ldc会将该对象的引用加载到操作数栈上。
-
没有则创建新对象并加入常量池:如果字符串常量池中没有相同内容的字符串对象,JVM会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。
7.String#intern方法有什么作用?
String.intern()是一个native(本地)方法,用来处理字符串常量池中的字符串对象引用。它的工作流程可以概括为以下两种情况:
-
常量池中已有相同内容的字符串对象:如果字符串常量池中已经有一个与调用intern()方法的字符串内容相同的String对象,intern()方法会直接返回常量池中该对象的引用。
-
常量池中没有相同内容的字符串对象:如果字符串常量池中还没有一个与调用intern()方法的字符串内容相同的对象,intern()方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。
总结:
-
intern()方法的主要作用是确保字符串引用在常量池中的唯一性。
-
当调用intern()时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。
8.String类型的变量和常量做“+”运算时发生了什么?
JVM会根据参与运算的是常量还是变量,采取完全不同的处理策略。
(1)如果参与+运算的全部是字符串字面量(或者被final修饰的常量),JVM会在编译阶段直接把它们合并。例如:
String s1 = "Hello" + "World";
编译器(javac)非常聪明,它发现这两个都是死的字符串,没必要等到运行时再加。于是它直接把代码优化成了Strings1="HelloWorld";。
结果:"HelloWorld"会被放入字符串常量池中。如果你再定义一个Strings2="HelloWorld";,那么s1==s2的结果是true。
(2)如果参与运算的包含变量(没有被final修饰),JVM无法在编译期确定最终结果,因此必须在运行期动态创建。
String s1 = "Hello";
String s2 = s1 + "World";
-
JVM会在堆中创建一个StringBuilder对象(在Java9之后改为了StringConcatFactory,但逻辑类似)。
-
调用append("Hello")。
-
调用append("World")。
-
最后调用**toString()**方法。
结果:StringBuilder.toString()内部实际上是执行了newString()。因此,生成的s2对象存储在堆内存中,而不是常量池中。
异常
1.Exception和Error有什么区别?
在Java中,所有的异常都有一个共同的祖先java.lang包中的Throwable类。Throwable类有两个重要的子类:
-
Exception:程序本身可以处理的异常,可以通过catch来进行捕获。Exception又可以分为CheckedException(受检查异常,必须处理)和UncheckedException(不受检查异常,可以不处理)。
-
Error:Error属于程序无法处理的错误,我们不建议通过catch捕获。例如Java虚拟机运行错误(VirtualMachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。
2.ClassNotFoundException和NoClassDefFoundError的区别
ClassNotFoundException是Exception,发生在使用反射等动态加载时找不到类,是可预期的,可以捕获处理。
NoClassDefFoundError是Error,是编译时存在的类,在运行时链接不到了(比如jar包缺失),是环境问题,导致JVM无法继续。
3.CheckedException和UncheckedException有什么区别?
CheckedException即受检查异常,Java代码在编译过程中,如果受检查异常没有被catch或者throws关键字处理的话,就没办法通过编译。除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常。常见的受检查异常有:IO相关的异常、ClassNotFoundException、SQLException...。
UncheckedException即不受检查异常,Java代码在编译过程中,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException及其子类都统称为非受检查异常,常见的有:
-
NullPointerException(空指针错误)
-
IllegalArgumentException(参数错误比如方法入参类型错误)
-
NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)
-
ArrayIndexOutOfBoundsException(数组越界错误)
-
ClassCastException(类型转换错误)
-
ArithmeticException(算术错误)
-
SecurityException(安全错误比如权限不够)
-
UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)
-
……
4.更倾向于使用CheckedException还是UncheckedException?
默认使用UncheckedException,只在必要时才用CheckedException。
我们可以把UncheckedException(比如NullPointerException)看作是代码Bug。对待Bug,最好的方式是让它暴露出来然后去修复代码,而不是用try-catch去掩盖它。
一般来说,只在一种情况下使用CheckedException:当这个异常是业务逻辑的一部分,并且调用方必须处理它时。比如说,一个余额不足异常。这不是bug,而是一个正常的业务分支,我需要用CheckedException来强制调用者去处理这种情况,比如提示用户去充值。这样就能在保证关键业务逻辑完整性的同时,让代码尽可能保持简洁。
5.Throwable类常用方法有哪些?
StringgetMessage():返回异常发生时的详细信息
StringtoString():返回异常发生时的简要描述
StringgetLocalizedMessage():返回异常对象的本地化信息。使用Throwable的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()返回的结果相同
voidprintStackTrace():在控制台上打印Throwable对象封装的异常信息
6.try-catch-finally如何使用?
try块:用于捕获异常。其后可接零个或多个catch块,如果没有catch块,则必须跟一个finally块。
catch块:用于处理try捕获到的异常。
finally块:无论是否捕获或处理异常,finally块里的语句都会被执行。当在try块或catch块中遇到return语句时,finally语句块将在方法返回之前被执行。
**不要在finally语句块中使用return!**当try语句和finally语句中都有return语句时,try语句块中的return语句会被忽略。这是因为try语句中的return返回值会先被暂存在一个本地变量中,当执行到finally语句中的return之后,这个本地变量的值就变为了finally语句中的return返回值。
7.finally中的代码一定会执行吗?
不一定的!在某些情况下,finally中的代码不会被执行。
就比如说finally之前虚拟机被终止运行的话,finally中的代码就不会被执行。
另外,在以下2种特殊情况下,finally块的代码也不会被执行:
-
程序所在的线程死亡。
-
关闭CPU。
7.如何使用try-with-resources代替try-catch-finally?
**适用范围(资源的定义):**任何实现java.lang.AutoCloseable或者java.io.Closeable的对象
**关闭资源和finally块的执行顺序:**在try-with-resources语句中,任何catch或finally块在声明的资源关闭后运行
Java中类似于InputStream、OutputStream、Scanner、PrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-with-resources语句来实现这个需求,
8.异常使用有哪些需要注意的地方?
(1)不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动new一个异常对象抛出。
(2)抛出的异常信息一定要有意义。
(3)建议抛出更加具体的异常,比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException。
(4)避免重复记录日志:如果在捕获异常的地方已经记录了足够的信息(包括异常类型、错误信息和堆栈跟踪等),那么在业务代码中再次抛出这个异常时,就不应该再次记录相同的错误信息。重复记录日志会使得日志文件膨胀,并且可能会掩盖问题的实际原因,使得问题更难以追踪和解决。
泛型
1.什么是泛型
**Java泛型(Generics)**是JDK5中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法
泛型类写法:在名称后面加上占位符,常见的占位符有:T(Type),E(Element),K(Key),V(Value)。例如:
public class Box<T> {
private T data;
public void set(T data) {
this.data = data;
}
public T get() {
return data;
}
}
泛型接口和类一样:
public interface Generator<T> {
T method();
}
实现泛型接口:
class GeneratorImpl<T> implements Generator<T> {
@Override
public T method() {
return null;
}
}
泛型方法:泛型方法独立于类,方法前面的<T>声明了这是一个泛型方法。
public <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
2.关于静态泛型方法
注意:publicstatic<E>voidprintArray(E[]inputArray)一般被称为静态泛型方法;在java中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的<E>
3.通配符
上界通配符:<?extendsT>
-
含义:接收T或T的子类。
-
场景:主要用于读取数据(生产者)。你可以确定取出来的一定是T。
下界通配符:<?superT>
-
含义:接收T或T的父类。
-
场景:主要用于写入数据(消费者)。你可以安全地往里存T。
4.类型擦除
Java的泛型只存在于编译期,运行期是不存在的。
-
过程:在编译成字节码时,所有的泛型信息都会被擦除,替换为它们的原始类型(通常是Object,或者是限定类型)。
-
证明:List<String>和List<Integer>在运行时的getClass()结果是完全一样的。
5.泛型的限制
不能使用基本类型:不能写List<int>,必须用包装类List<Integer>。
不能直接创建泛型数组:newT[10]是非法的(因为运行时不知道T的具体类型)。
不能在静态变量/静态方法中使用类的泛型参数:静态成员属于类,不属于实例,而泛型参数是实例创建时确定的。
不能使用instanceof检查泛型类型:比如if(objinstanceofList<String>)是不行的,只能检查instanceofList。
反射
1.什么是反射
Java反射(Reflection)是一种在程序运行时,动态地获取类的信息并操作类或对象(方法、属性)的能力。
通常情况下,我们写的代码在编译时类型就已经确定了,要调用哪个方法、访问哪个字段都是明确的。但反射允许我们在运行时才去探知一个类有哪些方法、哪些属性、它的构造函数是怎样的,甚至可以动态地创建对象、调用方法或修改属性,哪怕这些方法或属性是私有的。
正是这种在运行时“反观自身”并进行操作的能力,使得反射成为许多通用框架和库的基石。它让代码更加灵活,能够处理在编译时未知的类型。
2.反射的优缺点
优点:
-
灵活性和动态性:反射允许程序在运行时动态地加载类、创建对象、调用方法和访问字段。这样可以根据实际需求(如配置文件、用户输入、注解等)动态地适应和扩展程序的行为,显著提高了系统的灵活性和适应性。
-
框架开发的基础:许多现代Java框架(如Spring、Hibernate、MyBatis)都大量使用反射来实现依赖注入(DI)、面向切面编程(AOP)、对象关系映射(ORM)、注解处理等核心功能。反射是实现这些“魔法”功能不可或缺的基础工具。
-
解耦合和通用性:通过反射,可以编写更通用、可重用和高度解耦的代码,降低模块之间的依赖。例如,可以通过反射实现通用的对象拷贝、序列化、Bean工具等。
缺点:
-
性能开销:反射操作通常比直接代码调用要慢。因为涉及到动态类型解析、方法查找以及JIT编译器的优化受限等因素。不过,对于大多数框架场景,这种性能损耗通常是可以接受的,或者框架本身会做一些缓存优化。
-
安全性问题:反射可以绕过Java语言的访问控制机制(如访问private字段和方法),破坏了封装性,可能导致数据泄露或程序被恶意篡改。此外,还可以绕过泛型检查,带来类型安全隐患。
-
代码可读性和维护性:过度使用反射会使代码变得复杂、难以理解和调试。错误通常在运行时才会暴露,不像编译期错误那样容易发现。
3.反射的应用场景
很多流行的框架,比如Spring/SpringBoot、MyBatis等,底层都大量运用了反射机制,这才让它们能够那么灵活和强大。
1.依赖注入与控制反转(IoC)
以Spring/SpringBoot为代表的IoC框架,会在启动时扫描带有特定注解(如@Component,@Service,@Repository,@Controller)的类,通过Class.forName()加载类,再调用newInstance()实例化。再通过反射获取字段上的@Autowired注解,然后调用field.set(bean,instance)注入依赖。
2.注解处理
注解本身只是个“标记”,没有逻辑。真正让注解“活”起来的是反射。
-
JUnit测试框架:当你运行单元测试时,JUnit会用反射扫描你的类,找到所有带有@Test注解的方法,然后逐个调用它们。
-
Jackson/Fastjson:在序列化JSON时,通过反射读取对象属性,将其转换为字符串;反序列化时,再通过反射创建对象并填充属性。
3.动态代理与AOP
动态代理允许你在不修改原始代码的情况下,给方法增加功能(如日志记录、事务管理、权限校验)。
JDK动态代理:利用java.lang.reflect.Proxy类,在运行时动态地创建一个实现了指定接口的新类。
场景:你在方法上加个@Transactional,Spring就会用反射生成一个代理对象。当你调用方法时,代理对象先开启事务,再通过反射method.invoke()执行你的业务逻辑,最后提交事务。
4.对象关系映射(ORM)
像MyBatis、Hibernate这种框架,能帮你把数据库查出来的一行行数据,自动变成一个个Java对象。它是怎么知道数据库字段对应哪个Java属性的?还是靠反射。它通过反射获取Java类的属性列表,然后把查询结果按名字或配置对应起来,再用反射调用setter或直接修改字段值。反过来,保存对象到数据库时,也是用反射读取属性值来拼SQL。
代理
1.如何实现动态代理
在Java中,实现动态代理最主流的方式有两种:JDK动态代理和CGLIB动态代理。
(1)JDK动态代理(基于接口)
Java官方提供的(Java反射包(java.lang.reflect)提供的标准实现),其核心要求是目标类必须实现一个或多个接口。JDK动态代理在运行时,会利用Proxy.newProxyInstance()方法,动态地创建一个实现了这些接口的代理类的实例。这个代理类在内存中生成,你看不到它的.java或.class文件。
当你调用代理对象的任何一个方法时,这个调用都会被转发到我们提供的一个InvocationHandler接口的invoke方法中。在invoke方法里,我们就可以在调用原始方法(目标方法)之前或之后,加入我们自己的增强逻辑。
(2)CGLIB动态代理(基于继承)
如果一个类没有实现任何接口,那么JDK代理无法实现。CGLIB是一个第三方的代码生成库。它的原理与JDK完全不同,它不要求被代理的类实现接口。它在运行时,动态生成目标类的子类作为代理类(通过ASM字节码操作技术)。然后,它会重写父类(也就是被代理类)中所有非final、private和static的方法。
当你调用代理对象的任何一个方法时,这个调用会被CGLIB的MethodInterceptor接口的intercept方法拦截。和InvocationHandler的invoke方法一样,我们可以在intercept方法里,在调用原始的父类方法之前或之后,加入我们的增强逻辑。
2.JDK动态代理和CGLIB动态代理有什么区别?
| 特性 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 实现原理 | 反射+动态生成代理类 | ASM字节码技术+生成子类 |
| 限制 | 必须实现接口 | 不能代理final类或final方法 |
| 效率 | 现代JDK中性能已经非常高 | 首次生成类较慢,后续调用快 |
| 推荐场景 | 业务代码有接口定义时 | 普通类、第三方库类无接口时 |
JDK动态代理是官方的,它要求被代理的类必须实现接口。它的原理是动态生成一个接口的实现类来作为代理。CGLIB是第三方的,它不需要接口。它的原理是动态生成一个被代理类的子类来作为代理。但也正因为是继承,所以它不能代理final的类,被代理的方法也不能是final或private。
就二者的效率来说,大部分情况都是JDK动态代理更优秀,随着JDK版本的升级,这个优势更加明显。
3.静态代理和动态代理有什么区别?
(1)静态代理在程序运行前,代理类的.class文件就已经存在了。通常由程序员直接手动编写。
实现原理:代理类和目标类实现同一个接口。代理类内部持有一个目标类的引用,在调用目标方法前后插入自己的逻辑。
优点:逻辑直接,易于理解,不需要反射,执行速度最快。
缺点:产生大量的冗余代码。如果你有100个Service类需要加日志,你就得写100个代理类。一旦接口增加一个方法,目标类和代理类都要跟着改。
(2)动态代理在程序运行时,通过反射或字节码技术动态地在内存中创建代理对象。
实现原理:
-
JDK代理:利用InvocationHandler接口和Proxy类。
-
CGLIB代理:通过继承目标类并拦截方法调用。
优点:极高的灵活性。一个代理处理器(Handler)可以服务于无数个目标类。你只需要写一次增强逻辑(如事务管理),就可以应用到整个项目的成百上千个方法上。
缺点:由于涉及到反射或字节码生成,首次调用或创建时的性能开销比静态代理略大(但在现代JVM中这种差距已经极小)。
| 特征 | 静态代理 | 动态代理 |
|---|---|---|
| 创建时机 | 编译期(代码写死的) | 运行期(动态生成的) |
| 代理类数量 | 每个接口都要对应一个代理类 | 一个Handler对应无数个类 |
| 代码冗余度 | 高 | 低 |
| 灵活性 | 差,增加方法需同步修改 | 强,自动适配方法变化 |
| 底层依赖 | 接口继承 | 反射(JDK)或字节码(CGLIB) |
| 维护成本 | 高 | 低 |
3.介绍一下动态代理在框架中的实际应用场景
动态代理最典型的应用场景就是SpringAOP。
AOP(Aspect-OrientedProgramming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
SpringAOP就是基于动态代理的,如果要代理的对象,实现了某个接口,那么SpringAOP会使用JDKProxy,去创建代理对象,而对于没有实现接口的对象,就无法使用JDKProxy去进行代理了,这时候SpringAOP会使用Cglib生成一个被代理对象的子类来作为代理,如下图所示:
注解
1.什么是注解
Annotation(注解)是Java5开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
注解本质是一个继承了Annotation的特殊接口
2.注解的解析方法有哪几种
编译期直接扫描:编译器在编译Java代码的时候扫描对应的注解并处理,比如某个方法使用@Override注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
运行期通过反射处理:像框架中自带的注解(比如Spring框架的@Value、@Component)都是通过反射来进行处理的。
SPI
1.什么是SPI
SPI即ServiceProviderInterface,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。**由“调用方”定义接口,而由“实现方”提供具体的实现逻辑。**这样,程序在运行时就能动态地加载不同的实现类,而不需要在代码里硬编码
SPI将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
经典案例:JDBC驱动加载
这是JavaSPI最成功的应用。
JDK定义标准:java.sql.Driver接口。
各厂商实现:
(1)MySQL提供的Jar包里有:META-INF/services/java.sql.Driver,内容是com.mysql.cj.jdbc.Driver。
(2)PostgreSQL提供的Jar包里也有同名文件,内容是org.postgresql.Driver。
自动发现:当你执行DriverManager.getConnection()时,Java会自动去扫描所有Jar包里的配置文件,把对应的驱动加载进来。你不需要写Class.forName("...")了。
2.SPI和API有什么区别?
当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
当接口存在于调用方这边时,这就是SPI。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
可以通过“控制权”的归属来一眼看穿它们的区别:
API(ApplicationProgrammingInterface)
-
定义者:实现方(Provider)。
-
使用者:调用方(Client)。
-
逻辑:实现方说:“我有这些功能,你按我的规则调用就行。”
-
例子:Java的ArrayList类提供了add()方法。它是API,你直接用。
SPI(ServiceProviderInterface)
-
定义者:调用方(Client/Standard)。
-
使用者:实现方(Provider)。
-
逻辑:调用方说:“我定义了一个标准,谁想给我提供服务,就得按我的标准来实现。”
-
例子:JDBC。Java定义了Driver接口(SPI),MySQL和Oracle厂商去实现它。
3.SPI优缺点
通过SPI机制能够大大地提高接口设计的灵活性,但是SPI机制也存在一些缺点,比如:
-
需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
-
当多个ServiceLoader同时load时,会有并发问题。
序列化与反序列化
1.什么是序列化?什么是反序列化?
需要持久化Java对象比如将Java对象保存在文件中,或者在网络传输Java对象,这些场景都需要用到序列化。
-
序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是JSON,XML等文本格式
-
反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
对于Java这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class)。
几个序列化的场景:
(1)对象在进行网络传输之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
(2)将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
(3)将对象存储到数据库之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
(4)将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。

2.序列化协议对应于TCP/IP4层模型的哪一层?
TCP/IP4层模型分为四层:
-
应用层
-
传输层
-
网络层
-
网络接口层
Figure1 OSI七层模型
表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。正好对应的是序列化和反序列化么所做的操作。
因为,OSI七层协议模型中的应用层、表示层和会话层对应的都是TCP/IP四层模型中的应用层,所以序列化协议属于TCP/IP协议应用层的一部分。
3.如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,使用transient关键字修饰。
transient关键字的作用是:阻止实例中那些用此关键字修饰的变量序列化;当对象被反序列化时,被transient修饰的变量值不会被持久化和恢复。
关于transient还有几点注意:
-
transient只能修饰变量,不能修饰类和方法。
-
transient修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int类型,那么反序列后结果就是0。
-
static变量因为不属于任何对象(Object),所以无论有没有transient关键字修饰,均不会被序列化。
4.常见的序列化协议有哪些?
JDK自带的序列化方式一般不会用,因为序列化效率低并且存在安全问题。比较常用的序列化协议有Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像JSON和XML这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
5.为什么不推荐使用JDK自带的序列化?
很少或者几乎不会直接使用JDK自带的序列化方式,主要有以下原因:
-
不支持跨语言调用:如果调用的是其他语言开发的服务的时候就不支持了。
-
性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
-
存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
I/O
1.JavaIO流了解吗?
IO即Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为IO流。IO流在Java中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
JavaIO流的40多个类都是从如下4个抽象类基类中派生出来的。
-
InputStream/Reader:所有的输入流的基类,前者是字节输入流,后者是字符输入流。
-
OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。
2.I/O流为什么要分为字节流和字符流呢?
(1)字节流的重要性(通用性)
计算机中所有的数据在底层都是以二进制的0和1形式存储的。无论是一个图片、音频、视频,还是一个普通的文本文件,在硬盘上都是以字节为单位的。
-
字节流的职责:只负责按照原始字节进行读取和写入,完全不关心这些字节对应什么字符。
-
代表类:InputStream,OutputStream(及其子类FileInputStream,FileOutputStream)。
-
适用场景:所有文件类型(图片、视频、压缩包、文本文件)
(2)字符流的重要性
在ASCII码时代,1个字符=1个字节。但到了Unicode(UTF-8,UTF-16)时代,1个字符可能占用2到4个字节。
痛点:如果只使用字节流,在读取文本时非常容易出现截断问题。
- 比如:一个汉字在UTF-8中占3个字节。如果你用字节流只读了前2个字节就停止了,或者刚好切在了字符的中间,就会产生乱码。
字符流的职责:在读取时,它会自动根据指定的字符集(如UTF-8)将字节序列转化为字符,确保不会出现半个字符的情况。
场景:假设你要处理一个2GB的日志文件,而你的JVM只分配了512MB堆内存。
字节流的局限:如果你试图“全部收集”,程序会直接抛出OutOfMemoryError。
字符流的优势:字符流支持流式处理(Streaming)。它像一个过滤器,一边从底层字节流读入几个字节,一边在缓冲区判断编码,凑够一个字符就“吐”给程序。这样你只需要极小的内存,就能处理无限大的文件。
3.JavaIO中的设计模式有哪些?
(1)装饰器(Decorator)模式
装饰器模式通过组合替代继承来扩展原始类的功能
模式的核心角色
-
抽象构件(Component):定义一个对象接口,可以给这些对象动态地添加职责。
-
具体构件(ConcreteComponent):被装饰的原始对象。
-
抽象装饰器(Decorator):持有一个抽象构件对象的引用,并实现相同的接口。
-
具体装饰器(ConcreteDecorator):具体的扩展功能,内部调用父类的operation(),并添加自己的逻辑。
在IO流中的体现:
| 角色 | JavaI/O中的类 | 作用 |
|---|---|---|
| 抽象构件(Component) | InputStream | 定义了读取字节的核心规范(如read()方法)。 |
| 具体构件(ConcreteComponent) | FileInputStream | “身体”。提供最基础的读取文件字节的能力,但没有缓存,性能较低。 |
| 抽象装饰器(Decorator) | FilterInputStream | “衣架”。它内部持有一个InputStream的引用,是所有装饰器的父类。 |
| 具体装饰器(Decorator) | BufferedInputStream | “外衣”。给读取操作加上了8KB的缓冲区,大幅提升性能。 |
| 具体装饰器(Decorator) | DataInputStream | “挂件”。让你可以直接读取int、boolean等基本数据类型。 |
在Java中,如果你看到一个类的构造函数接收的参数类型正是它自己继承的父类(或接口),那么它极大概率就是一个装饰器。
公式:new包装类(new被包装类())
(2)适配器(AdapterPattern)模式
适配器模式中被适配的对象或者类称为适配者(Adaptee),作用于适配者的对象或者类称为适配器(Adapter)。
适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
以字节流和字符流为例:
IO流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
InputStreamReader和OutputStreamWriter就是两个适配器(Adapter),同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader使用StreamDecoder(流解码器)对字节进行解码,**实现字节流到字符流的转换,**OutputStreamWriter使用StreamEncoder(流编码器)对字符进行编码,实现字符流到字节流的转换。
InputStream和OutputStream的子类是被适配者,InputStreamReader和OutputStreamWriter是适配器。
装饰器和适配器的区别:
| 特性 | 装饰器模式 | 适配器模式 |
|---|---|---|
| 目的 | 增强功能,动态添加职责 | 转换接口,让不兼容的类能用 |
| 层级 | 保持接口不变,功能升级 | 修改接口,不改变功能 |
| 透明性 | 对客户透明,扩展后还是原接口 | 对客户不透明,客户用的是新接口 |
装饰器模式更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
适配器模式更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说StreamDecoder(流解码器)和StreamEncoder(流编码器)就是分别基于InputStream和OutputStream来获取FileChannel对象并调用对应的read方法和write方法进行字节数据的读取和写入。
(3)工厂模式
工厂模式用于创建对象,NIO中大量用到了工厂模式,比如Files类的newInputStream方法用于创建InputStream对象(静态工厂)、Paths类的get方法创建Path对象(静态工厂)、ZipFileSystem类(sun.nio包下的类,属于java.nio相关的一些内部实现)的getPath的方法创建Path对象(简单工厂)。
(4)观察者模式
NIO中的文件目录监听服务使用到了观察者模式。
NIO中的文件目录监听服务基于WatchService接口和Watchable接口。WatchService属于观察者,Watchable属于被观察者。
4.BIO、NIO和AIO的区别?
UNIX系统下,IO模型一共有5种:同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步I/O。
(1)BIO(BlockingI/O)
BIO属于同步阻塞IO模型。同步阻塞IO模型中,应用程序发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。因此,我们需要一种更高效的I/O处理模型来应对更高的并发量。
(2)NIO(Non-blocking/NewI/O)
Java中的NIO于Java1.4中引入,对应java.nio包,提供了Channel,Selector,Buffer等抽象。NIO中的N可以理解为Non-blocking,不单纯是New。它是支持面向缓冲的,基于通道的I/O操作方法。对于高负载、高并发的(网络)应用,应使用NIO。Java中的NIO可以看作是I/O多路复用模型。
同步非阻塞IO模型中,应用程序会一直发起read调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞IO模型,同步非阻塞IO模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
同步非阻塞IO,发起一个read调用,如果数据没有准备好,这个时候应用程序可以不阻塞等待,而是切换去做一些小的计算任务,然后很快回来继续发起read调用,也就是轮询。这个轮询不是持续不断发起的,会有间隙,这个间隙的利用就是同步非阻塞IO比同步阻塞IO高效的地方。
但是,这种IO模型同样存在问题:应用程序不断进行I/O系统调用轮询数据是否已经准备好的过程是十分消耗CPU资源的。
IO多路复用模型中,线程首先发起select调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起read调用。read调用的过程(数据从内核空间->用户空间)还是阻塞的。
(3)AIO(AsynchronousI/O)
AIO也就是NIO2。Java7中引入了NIO的改进版NIO2,它是异步IO模型。
异步IO是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
语法糖
1.什么是语法糖
**语法糖(Syntacticsugar)**代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
2.Java中有哪些常见的语法糖?
Java中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强for循环、try-with-resources语法、lambda表达式等。
集合
Java集合,也叫作容器,主要是由两大接口派生而来:一个是Collection接口,主要用于存放单一元素;另一个是Map接口,主要用于存放键值对。对于Collection接口,下面又有三个主要的子接口:List、Set、Queue。
1.List,Set,Queue,Map四者的区别?
(1)List:存储的元素是有序的、可重复的、索引访问。
常用实现类:
ArrayList:底层是动态数组。查询极快,但中间插入或删除元素需要移动大量数据,速度较慢。
LinkedList:底层是双向链表。增删元素非常快,但查询需要从头遍历,速度慢。
Vector:古老的实现,线程安全但性能低,现在基本被CopyOnWriteArrayList取代。
(2)Set:存储的元素无序,不可重复的,最多一个null。
HashSet:基于HashMap实现,查找和插入性能极高,但不保证顺序。
LinkedHashSet:在HashSet基础上增加了双向链表,能记录插入顺序。
TreeSet:底层是红黑树,可以根据元素的自然顺序或指定的比较器进行自动排序。
(3)Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的,遵循FIFO。
提供两套方法,一套在操作失败时抛异常(add),另一套返回特殊值(offer返回false)。
常用实现类:
PriorityQueue:优先级队列。元素不是按时间排队,而是按“重要性”(排序顺序)出队。
ArrayDeque:双端队列(Deque)。既可以当“先进先出”的队列用,也可以当后进先出的栈(Stack)用。注意:Java官方推荐用它替代过时的Stack类。
(4)Map:使用键值对(key-value)存储,key是无序的、不可重复的,value是无序的、可重复的,每个键最多映射到一个值。
常用实现类:
HashMap(最常用:无序、高效)
底层结构:Java8之后采用**“数组+链表+红黑树”**。
特点:
-
无序:存进去的顺序和取出来的顺序不一致。
-
高性能:插入、查找、删除的平均时间复杂度都是$O(1)$。
-
允许Null:Key和Value都可以为null(但Key只能有一个null)。
LinkedHashMap(有序的HashMap)
是HashMap的子类,在HashMap的基础上增加了一条双向链表。
特点:
-
有序:它记录了元素的插入顺序。遍历时,先存进去的先出来。
-
空间换时间:因为多了链表维护顺序,内存占用比HashMap略高。
TreeMap(按Key排序)
特点:
-
自动排序:它会根据Key的自然顺序(如数字从小到大、字符串字典序)或自定义的Comparator进行排序。
-
性能:查找、插入的时间复杂度为$O(\logn)$,比HashMap慢,但比链表快。
-
不允许NullKey:因为它要进行比较排序,所以Key不能为null。
Hashtable&ConcurrentHashMap(线程安全)
Hashtable(过时):古老的实现,通过给整个方法加synchronized锁来实现安全,效率极低,现在基本被废弃。
ConcurrentHashMap(现代主流):
-
Java7:采用“分段锁(Segment)”。
-
Java8+:采用“CAS+synchronized”锁住链表头/树根节点。
2.ArrayList和Array(数组)的区别?
ArrayList内部基于动态数组实现,比Array(静态数组)使用起来更加灵活:
-
ArrayList会根据实际存储的元素动态地扩容或缩容,而Array被创建之后就不能改变它的长度了。
-
ArrayList允许使用泛型来确保类型安全,Array则不可以。
-
ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如Integer、Double等)。Array可以直接存储基本类型数据,也可以存储对象。
-
ArrayList支持插入、删除、遍历等常见操作,并且提供了丰富的API操作方法,比如add()、remove()等。Array只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。
-
ArrayList创建时不需要指定大小,而Array创建时必须指定大小。
3.ArrayList插入和删除元素的时间复杂度?
插入:
头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是O(n)。
尾部插入:当ArrayList的容量未达到极限时,往列表末尾插入元素的时间复杂度是O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次**O(n)**的操作将原数组复制到新的更大的数组中,然后再执行O(1)的操作添加元素。
指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均n/2个元素,因此时间复杂度为O(n)。
删除:
头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是O(n)。
尾部删除:当删除的元素位于列表末尾时,时间复杂度为O(1)。
指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均n/2个元素,时间复杂度为O(n)。
| 操作 | 头部 | 尾部 | 指定位置 |
|---|---|---|---|
| 插入 | O(n) | 未达上限O(1) 达到上限O(n) |
O(n) |
| 删除 | O(n) | O(1) | O(n) |
4.LinkedList插入和删除元素的时间复杂度?
头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为O(1)。
尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为O(1)。
指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均n/4个元素,时间复杂度为O(n)。
5.LinkedList为什么不能实现RandomAccess接口?
RandomAccess是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于LinkedList底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现RandomAccess接口。
6.ArrayList与LinkedList区别?
**是否保证线程安全:**ArrayList和LinkedList都是不同步的,也就是不保证线程安全;
底层数据结构:ArrayList底层使用的是Object数组;LinkedList底层使用的是双向链表数据结构(JDK1.6之前为循环链表,JDK1.7取消了循环。注意双向链表和双向循环链表的区别)
**插入和删除是否受元素位置的影响:**ArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。
LinkedList采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响,时间复杂度为O(1)
**是否支持快速随机访问:**LinkedList不支持高效的随机元素访问,而ArrayList(实现了RandomAccess接口)支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(intindex)方法)。
**内存空间占用:**ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。
7.ArrayList的扩容机制
在JDK8中,ArrayList有三种方式来初始化:
(1)无参构造:使用初始容量10(默认值)构造一个空列表
以无参数构造方法创建ArrayList时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为10。
(2)带初始容量参数的构造函数。(用户自己指定容量)
(3)构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回
扩容步骤
第一步:检查空间
在添加元素前,ArrayList会先计算当前数组的剩余空间。如果当前容量(capacity)已经等于元素数量(size),就会触发扩容逻辑。
第二步:计算新容量
扩容并不是增加一个位置,而是按照原容量的1.5倍进行扩容。
-
计算公式:newCapacity=oldCapacity+(oldCapacity>>1)。
-
使用位运算符>>1(右移一位,相当于除以2)是因为位运算比直接除法效率更高。
-
**oldCapacity为偶数就是1.5倍,否则是1.5倍左右!**奇偶不同,比如:10+10/2=15,33+33/2=49。如果是奇数的话会丢掉小数.
第三步:申请空间与数据拷贝
-
创建一个长度为newCapacity的新数组。
-
调用System.arraycopy()(这是一个本地方法,性能极高)将旧数组中的所有元素原封不动地搬运到新数组中。
-
将ArrayList内部的引用指向这个新数组,旧数组随后会被GC(垃圾回收)处理。
特殊情况
初始容量:0->10
-
如果你通过newArrayList<>()创建对象,Java并不会立刻分配内存。
-
只有当你第一次add元素时,数组才会初始化,默认初始容量为10。
-
这种设计叫做延迟初始化,目的是节省内存(防止创建了List但一直不用)。
最大容量:Integer.MAX_VALUE-8
- 数组的最大容量受到JVM的限制。减去8是因为某些JVM会在数组头存储一些元数据,直接申请MAX_VALUE可能会导致OutOfMemoryError。
为什么是1.5倍?
-
如果倍数太大(比如10倍):会造成严重的内存浪费。
-
如果倍数太小(比如1.1倍):会导致扩容过于频繁。扩容是一项耗时操作(需要申请空间和拷贝数据),频繁扩容会拉低程序性能。
-
1.5倍是在内存利用率和性能损耗之间取得的一个平衡点。
8.集合中的fail-fast和fail-safe是什么?
fail-fast(快速失败)和fail-safe(安全失败)是Java集合框架在处理并发修改问题时,两种截然不同的设计哲学和容错策略。
(1)快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。
在java.util包下的大部分集合(如ArrayList,HashMap)是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个modCount记录修改的次数,迭代期间通过比对预期修改次数expectedModCount和modCount是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。
(2)而fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境。
该思想常运用于并发容器,最经典的实现就是CopyOnWriteArrayList的实现,通过写时复制(Copy-On-Write)的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将CopyOnWriteArrayList底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存在缺点,即进行遍历操作时无法获得实时结果
9.Comparable和Comparator的区别
Comparable是“内部”排序规则,而Comparator是“外部”定制规则。
Comparable位于java.lang包下。如果一个类实现了它,就意味着这个类的对象天生支持排序。
-
方法:intcompareTo(To)
-
特点:
-
耦合度高:必须修改类的源代码来实现接口。
-
单一性:一个类只能实现一个compareTo方法,这意味着它只有一种“默认”排序方式。
-
Comparator位于java.util包下。它是一个比较器,可以独立于类本身存在。
-
方法:intcompare(To1,To2)
-
特点:
-
解耦:不需要修改原始类的代码(比如你无法修改第三方库的源码,但想给它排序)。
-
灵活性:你可以定义多个比较器。今天按年龄排,明天按姓名排。
-
例如:
//定义一个外部比较器
Comparator<Student>nameComparator=(s1,s2)->s1.getName().compareTo(s2.getName());
//使用时:传入比较器
Collections.sort(students,nameComparator);
在Collections.sort()或Arrays.sort()中:
-
如果不传第二个参数,Java会寻找对象是否实现了Comparable。
-
如果传了第二个参数(Comparator),Java会优先使用你传入的比较器,而忽略对象自带的Comparable逻辑。
Comparable接口和Comparator接口都是Java中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:
-
Comparable接口实际上是出自java.lang包它有一个compareTo(Objectobj)方法用来排序
-
Comparator接口实际上是出自java.util包它有一个compare(Objectobj1,Objectobj2)方法用来排序
一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个song对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个Comparator来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的Collections.sort().
10.无序性和不可重复性的含义是什么
无序性不等于随机性,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加,而是根据数据的哈希值决定的。
不可重复性是指添加的元素按照equals()判断时,返回false,需要同时重写equals()方法和hashCode()方法。
11.比较HashSet、LinkedHashSet和TreeSet三者的异同
HashSet、LinkedHashSet和TreeSet都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。
HashSet、LinkedHashSet和TreeSet的主要区别在于底层数据结构不同。HashSet的底层数据结构是哈希表(基于HashMap实现)。LinkedHashSet的底层数据结构是链表和哈希表,元素的插入和取出顺序满足FIFO。TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
底层数据结构不同又导致这三者的应用场景不同。HashSet用于不需要保证元素插入和取出顺序的场景,LinkedHashSet用于保证元素的插入和取出顺序满足FIFO的场景,TreeSet用于支持对元素自定义排序规则的场景。
12.Queue与Deque的区别
Queue是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循**先进先出(FIFO)**规则。
Queue扩展了Collection的接口,根据因为容量问题而导致操作失败后处理方式的不同可以分为两类方法:一种在操作失败后会抛出异常(add,remove, element),另一种则会返回特殊值(offer, poll, peek)。
Deque是双端队列,在队列的两端均可以插入或删除元素。
Deque扩展了Queue的接口,增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类
13. ArrayDeque 与 LinkedList 的区别
ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?
-
ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
-
ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
-
ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
-
ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
14. 说一说 PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
-
PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
-
PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
-
PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
-
PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。
15. 什么是 BlockingQueue?
BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
当队列满时:如果生产者线程尝试往队列里丢数据,它会被阻塞(挂起),直到队列有了空位。
当队列空时:如果消费者线程尝试从队列里取数据,它会被阻塞(挂起),直到队列里有了新数据。

| 行为 | 抛出异常 | 返回特殊值 | 阻塞等待 | 超时退出 |
|---|---|---|---|---|
| 插入 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| 移除 | remove() | poll() | take() | poll(time, unit) |
| 检查 | element() | peek() | 不适用 | 不适用 |
16. BlockingQueue 的实现类有哪些?
Java 中常用的阻塞队列实现类有以下几种:
-
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
-
LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。
-
PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
-
SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。
-
DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
-
……
17. ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
-
底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。
-
是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。
-
锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。
-
内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。
18. HashMap 和 Hashtable 的区别
1. 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
2. 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
3. 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
4. 初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。
6. 哈希函数的实现:HashMap 对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 Hashtable 直接使用键的 hashCode() 值。
19. HashMap 和 HashSet 区别
HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
20. HashMap 和 TreeMap 区别
TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
NavigableMap 接口提供了丰富的方法来探索和操作键值对:
-
定向搜索: ceilingEntry(), floorEntry(), higherEntry()和 lowerEntry() 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。
-
子集操作: subMap(), headMap()和 tailMap() 方法可以高效地创建原集合的子集视图,而无需复制整个集合。
-
逆序视图:descendingMap() 方法返回一个逆序的 NavigableMap 视图,使得可以反向迭代整个 TreeMap。
-
边界操作: firstEntry(), lastEntry(), pollFirstEntry()和 pollLastEntry() 等方法可以方便地访问和移除元素。
这些方法都是基于红黑树数据结构的属性实现的,红黑树保持平衡状态,从而保证了搜索操作的时间复杂度为 O(log n),这让 TreeMap 成为了处理有序集合搜索问题的强大工具。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
21. HashSet 如何检查重复?
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。也就是说,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。
22. HashMap 的底层实现
(1)JDK1.8 之前
HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
HashMap 中的扰动函数(hash 方法)是用来优化哈希值的分布。通过对原始的 hashCode() 进行额外处理,扰动函数可以减小由于糟糕的 hashCode() 实现导致的碰撞,从而提高数据的分布均匀性。
拉链法就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
(2)JDK1.8 之后
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。
这样做的目的是减少搜索时间:链表的查询效率为 O(n)(n 是链表的长度),红黑树是一种自平衡二叉搜索树,其查询效率为 O(log n)。当链表较短时,O(n) 和 O(log n) 的性能差异不明显。但当链表变长时,查询性能会显著下降。
为什么优先扩容而非直接转为红黑树?
数组扩容能减少哈希冲突的发生概率(即将元素重新分散到新的、更大的数组中),这在多数情况下比直接转换为红黑树更高效。
红黑树需要保持自平衡,维护成本较高。并且,过早引入红黑树反而会增加复杂度。
为什么选择阈值 8 和 64?
-
泊松分布表明,链表长度达到 8 的概率极低(小于千万分之一)。在绝大多数情况下,链表长度都不会超过 8。阈值设置为 8,可以保证性能和空间效率的平衡。
-
数组长度阈值 64 同样是经过实践验证的经验值。在小数组中扩容成本低,优先扩容可以避免过早引入红黑树。数组大小达到 64 时,冲突概率较高,此时红黑树的性能优势开始显现。
23. HashMap 的长度为什么是 2 的幂次方
为了让 HashMap 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 int 表示,其范围是 -2147483648 ~ 2147483647前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
这个算法应该如何设计呢?
我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1) 的前提是 length 是 2 的 n 次方)。” 并且,采用二进制位操作 & 相对于 % 能够提高运算效率。
除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是:长度是 2 的幂次方,可以让 HashMap 在扩容的时候更均匀。例如:
-
length = 8 时,length - 1 = 7 的二进制位0111
-
length = 16 时,length - 1 = 15 的二进制位1111
这时候原本存在 HashMap 中的元素计算新的数组位置时 hash&(length-1),取决 hash 的第四个二进制位(从右数),会出现两种情况:
-
第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。
-
第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。

这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 length = 32 时,length - 1 = 31,二进制为 11111,这里看的就是第五个二进制位。
也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 hashcode() 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
最后,简单总结一下 HashMap 的长度是 2 的幂次方的原因:
-
位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,hash % length 等价于 hash & (length - 1)。
-
可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
-
扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
24. HashMap 多线程操作导致死循环问题
JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。
25. HashMap 为什么线程不安全?
HashMap 不是线程安全的。在多线程环境下对 HashMap 进行并发写操作,可能会导致两种主要问题:
-
数据丢失:并发 put 操作可能导致一个线程的写入被另一个线程覆盖。
-
无限循环:在 JDK 7 及以前的版本中,并发扩容时,由于头插法可能导致链表形成环,从而在 get 操作时引发无限循环,CPU 飙升至 100%。
数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。
JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
-
两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
-
不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
-
随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:
-
线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。
-
线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。
-
随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
-
线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。
26. HashMap 常见的遍历方式?
HashMap 的遍历主要有四种视角:EntrySet(键值对)、KeySet(键)、Values(值) 以及 Lambda 流(Java 8+)。
1. 遍历 EntrySet(性能最高)
Map<String, Integer> map = new HashMap<>();
// ...
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
2. 使用 Lambda 表达式(最简洁)
map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
3. 遍历 KeySet 或 Values
// 只遍历 Key
for (String key : map.keySet()) {
System.out.println("Key: " + key);
}
// 只遍历 Value
for (Integer value : map.values()) {
System.out.println("Value: " + value);
}
严禁在遍历 keySet 时再去调用 map.get(key) 来获取值。这会导致 O(1) 的操作变成 O(n) 甚至更糟,因为每次 get 都要重新计算 Hash。
4. 使用 Iterator(安全删除时必用)
如果你在遍历的过程中需要删除元素,必须使用 Iterator。直接在 for-each 循环中 remove 会抛出 ConcurrentModificationException。
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Integer> entry = it.next();
if (entry.getKey().equals("Target")) {
it.remove(); // 安全删除
}
}
27. ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,在 JDK1.8 中采用的数据结构跟 HashMap 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
-
在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
-
到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
-
Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
Hashtable结构
JDK1.7 的ConcurrentHashMap结构
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构
JDK1.8 的 ConcurrentHashMap结构
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | ❌ 否 | ✅ 是 (效率低) | ✅ 是 (效率高) |
| 底层实现 | 数组+链表+红黑树 (1.8) | 数组+链表 | 数组+链表+红黑树 (1.8) |
| 锁机制 | 无锁 | 全表锁 (synchronized) | 分段锁/桶锁(CAS + synchronized) |
| Null值 | 允许 Key/Value 为 null | 不允许 | 不允许 |
| 迭代器 | fail-fast | Enumeration / fail-fast | fail-safe (弱一致性) |
JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。
TreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
28. ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
(1)JDK1.8 之前
首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

总结:
第一层(ConcurrentHashMap):持有一个 Segment[] 数组。默认长度通常是 16。
第二层(Segment):数组的每个元素都是一把独立锁,里面包含一个 HashEntry[] 数组。
第三层(HashEntry 数组):数组的每个桶位(Bucket)存放一个 HashEntry 对象。
第四层(链表):如果发生哈希冲突,HashEntry 通过 next 指针连成链表。
Put 操作:
-
计算 Key 的哈希值。
-
定位到具体的 Segment。
-
调用 Segment.lock() 加锁。
-
在 Segment 内部的 HashEntry 数组中执行插入逻辑。
-
调用 Segment.unlock() 释放锁。
(2)JDK1.8 之后
ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
29. JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。
Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
30. ConcurrentHashMap 为什么 key 和 value 不能为 null?
(1)二义性介绍
ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。
拿 get 方法取值来说,返回的结果为 null 存在两种情况:
-
值没有在集合中 ;
-
值本身就是 null。
这也就是二义性的由来。
(2)为何要避免
多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。
与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。
也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。
31. ConcurrentHashMap 能保证复合操作的原子性吗?
ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}
这个示例用于说明:
containsKey+put不是原子操作,两个线程可能同时通过判断并互相覆盖写入结果。
32. 如何保证 ConcurrentHashMap 复合操作的原子性呢?
ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
33. 集合判空注意
判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。
因为 isEmpty() 方法的可读性更好,并且时间复杂度为 O(1)。
绝大部分我们使用的集合的 size() 方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 java.util.concurrent 包下的 ConcurrentLinkedQueue。ConcurrentLinkedQueue 的 isEmpty() 方法通过 first() 方法进行判断,其中 first() 方法返回的是队列中第一个值不为 null 的节点(节点值为null的原因是在迭代器中使用的逻辑删除)
在插入与删除元素时,都会执行updateHead(h, p)方法,所以该方法的执行的时间复杂度可以近似为O(1)。而 size() 方法需要遍历整个链表,时间复杂度为O(n)
在ConcurrentHashMap 1.7 中 size() 方法和 isEmpty() 方法的时间复杂度也不太一样。ConcurrentHashMap 1.7 将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。但是在ConcurrentHashMap 1.8 中的 size() 方法和 isEmpty() 都需要调用 sumCount() 方法,其时间复杂度与 Node 数组的大小有关。
因为在并发的环境下,ConcurrentHashMap 将每个 Node 中节点的数量存储在 CounterCell[] 数组中。在 ConcurrentHashMap 1.7 中,将元素数量存储在每个Segment 中,size() 方法需要统计每个 Segment 的数量,而 isEmpty() 只需要找到第一个不为空的 Segment 即可。
34. 集合转Map
在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。
原因:在 toMap 的底层实现中,它调用了 Map.merge() 方法。而 Map.merge() 的源码注释中明确规定:如果 value 为 null,直接抛出 NullPointerException。
35. 集合遍历
不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。
1. 触发 fail-fast 机制
foreach 语法糖本质上是使用 Iterator(迭代器)进行遍历。在遍历过程中,如果你直接调用集合本身的 list.remove() 方法,会改变集合的修改次数计数器 modCount。
-
冲突:迭代器内部维护了一个 expectedModCount(预期的修改次数)。
-
异常:当迭代器在下一次取元素(调用 next())时,发现 modCount != expectedModCount,就会判定有其他力量非法篡改了数据,立即抛出 ConcurrentModificationException 异常。
2. 避免逻辑错误(漏删或错位)
即使某些集合(如 CopyOnWriteArrayList)不会抛出异常,在普通循环中删除元素也会导致索引错位。
-
当你删除了索引为 i 的元素,后面的元素会自动前移填补空缺。
-
下一次循环 i++ 后,原本处于 i+1 位置的元素会被跳过,导致“漏删”。
3. Iterator.remove() 是合法的“特权”
为什么 Iterator.remove() 没问题?
-
同步状态:通过迭代器自己的 remove() 方法删除元素,它会在删除后同步更新 expectedModCount,使其与集合的 modCount 保持一致。
-
位置维护:迭代器内部知道当前遍历到了哪里,删除后会自动调整内部指针,确保遍历的连续性和正确性。
36.集合去重
可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 List 的 contains() 进行遍历去重或者判断包含操作。
37. 集合转数组
使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。
38. 数组转集合
使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。
1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型。
核心原因:泛型的限制
Arrays.asList(T... a) 使用了 Java 泛型。泛型要求类型必须是对象(Object)。
-
基本类型数组(如 int[])本身是一个对象。
-
但 int 这种基本数据类型(Primitive Type)并不是对象。
当你把 int[] 传给 asList 时,编译器无法把 int 当作 T。相反,它把整个 int[] 数组看作是一个符合 Object 要求的单一对象。
因此,asList 返回的其实是一个 List<int[]>(包含一个数组对象的列表),而不是 List<Integer>。
2、使用集合的修改方法: add()、remove()、clear()会抛出异常。
Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。
如何正确的将数组转换为 ArrayList ?
1、手动实现工具类
// JDK 1.5+
static <T> List<T> arrayToList(final T[] array) {
final List<T> list = new ArrayList<T>(array.length);
for (final T s : array) {
list.add(s);
}
return list;
}
Integer[] myArray = {1, 2, 3};
System.out.println(arrayToList(myArray).getClass()); // class java.util.ArrayList
2、最简便的方法
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
来源与许可:
- 参考来源:JavaGuide(javaguide.cn)
- 原文标题:JavaGuide(Java 面试& 后端通用面试指南)
- 原文链接:javaguide.cn/
- 原项目许可:MIT License
- 说明:本文仅作学习整理与分析,保留原项目版权声明与许可声明;原文及相关权利归原作者/项目方所有。