[学习](Java基础常见面试题总结(上) | JavaGuide(Java面试 + 学习指南))
画一些重点:
包装类型的缓存机制了解么?
1. Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,在这个范围内的便返回指向IntegerCache.cache中已经存在的对象的引用;否则创建一个新的Integer对象。所以
public class Main {
public static void main(String[] args) {
Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;
System.out.println(i1==i2); //true
System.out.println(i3==i4); //false
}
}
2. 两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。所以
public static void main(String[] args) {
Double i1 = 100.0;
Double i2 = 100.0;
Double i3 = 200.0;
Double i4 = 200.0;
System.out.println(i1==i2);//false
System.out.println(i3==i4);//false
}
}
3.Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。 当 "=="运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)。
自动装箱与拆箱了解吗?原理是什么?
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。
举例:
int n = i; //拆箱
因此,
Integer i = 10等价于Integer i = Integer.valueOf(10)int n = i等价于int n = i.intValue();
举例:
//为什么 应该使用 long 而不是 Long
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
在这段代码中,sum 变量被声明为 Long 对象而不是基本类型 long。使用 Long 对象会导致在每次循环中进行自动装箱(Autoboxing)和拆箱(Unboxing)操作。
在循环中,每次执行 sum += i; 时,会发生自动装箱操作将基本类型 long 装箱成 Long 对象,然后进行加法运算,之后再将 Long 对象拆箱成基本类型 long,然后赋值给 sum 变量。这种装箱和拆箱的操作会导致额外的性能开销和内存消耗。
考虑到性能和效率,这里应该使用基本类型 long 而不是 Long 对象。可以将 sum 声明为基本类型 long,然后进行累加操作,避免自动装箱和拆箱的开销:
private static long sum() {
long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
这样可以更高效地执行累加操作,因为它避免了在每次循环中进行不必要的装箱和拆箱操作。
==和equals
== : 比较的是栈中的值,对于基础数据类型,比较变量值。对于引用类型,比较栈内存中存储堆中对象的地址,用于比较两个引用是否指向同一个对象。
equals: equals()是Object类的一个方法,所有的Java对象都继承了它。默认情况下,equals()方法的行为与==相同,即比较的是对象的引用是否相等。
但是,许多类(如String、File、Date等)都重写了equals()方法,以便比较对象的内容是否相等。如果两个对象的内容相等,那么equals()返回true;否则返回false。
字符串常量+变量会产生一个新的变量。 备战“金九银十”10道String高频面试题解析-腾讯云开发者社区-腾讯云 (tencent.com)
接口与抽象类
抽象类包含了子类的通用特性,接口的特性是定义行为,即类可以做什么,至于实现类主体是什么,接口并不关心。
HashCode和equals
hashCode()方法用于计算对象的哈希码,这是一种将对象映射到一个整数的方式。哈希码的主要用途是在哈希表(如HashMap、HashSet等)中快速查找对象。当我们将对象添加到哈希表或者从哈希表中检索对象时,哈希表会使用对象的哈希码来确定对象在内部数据结构中的存储位置。因此,hashCode()方法的实现对于哈希表的性能有着直接的影响。
equals()方法则用于比较两个对象的内容是否相等。所有的Java对象都继承了Object类中的equals()方法,其默认行为是比较对象的引用是否相等,即判断两个对象是否指向同一块内存地址。然而,在实际编程中,我们通常需要根据对象的属性来确定它们是否相等,这就需要重写equals()方法。
需要注意的是,如果两个对象根据equals(Object)方法是相等的,那么调用这两个对象的hashCode方法必须产生相同的整数结果。也就是说,如果两个对象相等,那么它们的哈希码也必须相等。这是Java规范的要求,也是为了保证哈希表等数据结构能正确工作。
此外,在HashSet这样的集合中,为了保证元素的唯一性,我们必须重写hashCode()和equals()方法。这是因为HashSet在添加元素时,首先会根据元素的哈希码判断元素是否已经存在,如果哈希码相同,则再调用equals()方法进行比较。如果equals()方法返回true,则说明元素已存在,不添加;如果返回false,则说明元素不存在,添加到集合中。
HashMap和HashTable的区别
HashMap没有synchronized修饰,线程非安全。HashTable是线程按安全的。
HashMap允许key和value为空,HashTable则不行。
HashMap数组扩容的原理
HashMap在数组扩容时遵循以下原理:
-
触发扩容条件:
- 当HashMap中的元素数量(size)超过其当前容量(capacity)与负载因子(load factor)的乘积时,即
size > capacity * loadFactor,就会触发扩容操作。默认的负载因子为0.75,这意味着当元素数量达到容量的75%时,HashMap将自动扩容。
- 当HashMap中的元素数量(size)超过其当前容量(capacity)与负载因子(load factor)的乘积时,即
-
新容量计算:
- 扩容时,HashMap通常会将容量翻倍。也就是说,新的容量
newCapacity = capacity * 2。这样设计的原因是为了在大多数情况下保持良好的性能,因为每次扩容都会重新分配数组并转移所有元素,翻倍扩容可以减少扩容次数,尤其是在数据量持续增长的场景下。
- 扩容时,HashMap通常会将容量翻倍。也就是说,新的容量
-
创建新数组:
- 根据计算出的新容量,创建一个新的数组(Node<K, V>[])作为新的哈希桶。
-
重新哈希并转移元素:
- 遍历原数组中的每一个元素(Node),使用新的哈希函数(
(n - 1) & hash,其中n为新容量)计算元素在新数组中的位置。 - 将原数组中的元素转移到新数组相应的位置。如果新位置已经有元素(发生了哈希冲突),则通过链表或红黑树(Java 8引入)的形式将元素添加到该位置的链表或树尾部。
- 注意,由于扩容后哈希桶数量翻倍,原本在原数组中处于同一个槽位的链表可能被分散到两个不同的新槽位,这有助于降低哈希冲突的概率。
- 遍历原数组中的每一个元素(Node),使用新的哈希函数(
-
更新内部状态:
- 更新HashMap的内部状态,包括容量(capacity)、阈值(threshold = newCapacity * loadFactor)以及指向新数组的引用。
整个扩容过程伴随着大量的元素迁移操作,这是一个相对耗时的过程,但通过负载因子的设定和容量翻倍策略,HashMap能够在时间和空间成本之间取得平衡,确保在大部分情况下维持合理的查询性能。在高并发环境下,为了减少扩容期间的阻塞,Java 8引入了“ConcurrentHashMap”类,它使用分段锁技术来并发地迁移部分元素,进一步提高了并发扩容的性能。
ConcurrentHashMap原理,jdk7和8的区别
如何实现一个IOC容器
什么是字节码,字节码有什么好处?
java类加载器有哪些
双亲委派模型
双亲委派模型的缺点
java异常类
GC roots包含的对象
GC roots(Garbage Collection Roots)是Java垃圾回收机制中的一个重要概念,它们是一组特殊的对象,作为垃圾收集器进行可达性分析的起点。当垃圾收集器进行内存回收时,会从这些GC roots出发,通过引用关系向下遍历对象图,标记所有可达的对象为存活,而未被标记到的对象则被认为是不可达的,可以被垃圾收集器回收。
GC roots通常包括以下几种对象:
-
虚拟机栈(VM Stack)中的局部变量:
- 正在执行的方法中的局部变量、方法参数等。当一个方法被调用时,会在对应的栈帧中为其创建局部变量表,其中的引用对象就是GC roots。
-
方法区(Method Area)中的静态变量:
- 类的静态字段(static fields)。无论类的实例是否被创建,静态变量始终存在于方法区中,其引用的对象也是GC roots。
-
方法区中的常量引用:
- 字符串常量池中的字符串、以及其他编译期可知的常量引用的对象。这些常量在类加载阶段就被放入方法区,其引用的对象同样作为GC roots。
-
本地方法栈(Native Method Stack)中的JNI(Java Native Interface)引用:
- 当Java代码通过JNI调用本地(非Java)代码时,本地代码可能会持有对Java对象的引用。这些引用在本地方法栈中,也是GC roots的一部分。
-
Java虚拟机内部对象:
- JVM内部数据结构中直接引用的对象,如系统类加载器、异常处理器表等。这些对象由JVM创建和维护,不在用户代码的控制范围内,但对垃圾收集至关重要。
-
线程上下文中的引用:
- 每个线程都有一个ThreadLocal变量列表,用于存储线程私有的数据。这些ThreadLocal变量所引用的对象也是GC roots。
-
已注册的JNI全局引用:
- 通过JNI的
NewGlobalRef函数创建的全局引用,除非显式调用DeleteGlobalRef释放,否则一直作为GC root存在。
- 通过JNI的
总之,GC roots是垃圾收集器进行可达性分析的根节点,它们确保了正在执行的代码、静态数据、JNI引用以及JVM内部数据结构中直接引用的对象不会被错误地回收。这些对象构成了垃圾收集的根集,垃圾收集器正是从这些根开始遍历对象图,判断对象是否可达,从而决定哪些对象需要被回收。
GC判断对象可被回收
- 引用计数法。存在循环引用的问题。
- 可达性分析。从GC Roots往下搜索,搜索所走过的路径称为一个引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机判断是可回收对象。
线程的生命周期以及状态
sleep()、wait()、join()、yield()的区别
20-sleep、wait、join、yield_哔哩哔哩_bilibili
为什么先调start()再调join()
您提到的“start”和“join”通常是在讨论多线程编程中的线程操作。在Java、C++等支持多线程编程的语言中,start() 方法用于启动一个新的线程,而 join() 方法用于让当前线程等待指定线程结束。这里讨论的是为什么通常需要先调用 start() 再调用 join(),而不是相反。
1. 线程生命周期与方法作用
-
start(): 当调用线程对象的
start()方法时,会为该线程创建一个新的执行上下文,并将其放入就绪队列。操作系统调度器会选择合适的时机从就绪队列中取出该线程并为其分配CPU时间片,使线程开始执行其内部定义的run()方法。调用start()并不意味着立即执行线程的代码,而是将其安排到后台异步执行。 -
join(): 调用线程对象的
join()方法时,当前线程会阻塞(暂停执行),直到指定的线程(即join()的调用对象)执行完毕。这意味着当前线程会等待指定线程的run()方法执行完成,获取其终止状态,然后才恢复自身的执行。
2. 先调用 start() 后调用 join() 的原因
顺序要求:
- 逻辑要求:线程的
join()方法要求目标线程已经处于运行状态或已完成状态。若目标线程尚未启动(即未调用start()),调用join()将无意义,因为此时没有可供等待的执行过程。因此,逻辑上要求先调用start()来启动线程,再调用join()来等待其完成。
并发协作:
- 同步需求:在很多情况下,我们使用多线程是为了实现任务的并行执行,但有时主线程需要等待某个子线程完成特定任务后再进行下一步操作。例如,主线程可能依赖子线程计算的结果。这时就需要先调用
start()启动子线程,接着调用join()让主线程等待子线程执行结束,确保子线程的工作在主线程继续执行之前完成。
避免死锁:
- 避免死锁风险:如果先调用
join()再调用start(), 可能会导致死锁。因为当主线程试图等待尚未启动的子线程时,子线程可能由于主线程阻塞而无法获得执行机会,从而形成死锁。遵循先start()后join()的顺序可以避免这种风险。
总结:
先调用 start() 再调用 join() 符合线程生命周期的自然流程,满足线程间同步的需求,并且能有效防止潜在的死锁问题。这是在多线程编程中推荐的线程启动与等待的正确操作顺序。
对线程安全的理解
Thread和Runable的区别
守护线程
创建线程的方法
- 继承Thread类创建线程类:定义Thread类的子类并重写该类的run方法,然后创建Thread子类的实例并调用其start()方法来启动线程。
- 通过Runnable接口创建线程类:定义Runnable接口的实现类并重写其run()方法,然后创建实现类的实例并将其作为参数传递给Thread类的构造器创建Thread对象,最后调用Thread对象的start()方法启动线程。
- 通过Callable和Future创建线程:创建Callable接口的实现类并实现call()方法,该call()方法将作为线程执行体且有返回值。然后创建FutureTask对象,将Callable实现类的实例作为参数传递给FutureTask构造器,并使用FutureTask对象作为Thread对象的target创建并启动新线程。调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
- 使用线程池:
线程池好处:
1.提高响应速度(减少了创建新线程的时间)
2.降低资源消耗(重复利用线程池中线程,不需要每次都创建)
3.便于线程管理
- 使用匿名内部类
ThreadLocal的原理和使用场景
ThreadLocal内存泄漏以及原因
并发,并行,串行
并发三大特性
原子性
关键字:synchronized
比如i++不是线程安全的,有两个线程交替执行前三个步骤。
可见性
多个线程都可修改一个值,一个线程修改了值,另一个线程立即看到变化。 原理:总线LOCk和MESI。
有序性
虚拟机可能将指令顺序进行重排。
用synchronized对代码块加锁,用volatile对变量加锁。