前言
为什么要梳理?
- 八股文是java面试的必考点,有些公司八股文面试问的很多,有些公司只是简单问问,看你是否了解一些理论基础。
- 既然是吃java开发这碗饭的,为了个人成长,了解一些理论知识,还是很有必要的。
如何梳理的?
- 主要梳理出现在真实面试中出现的高频八股文考点
- 参考了很多网上前辈的文章,并非全部原创,会标注来源
欢迎通过评论,指正、共创
java基础
java代码是怎么运行起来的?
-
将 java 文件编译为 class 文件
-
将 class 文件加载到 JVM
-
JVM 将 字节码 翻译成 机器码 ,有两种形式:
-
第一种是解释执行,即逐条将字节码翻译成机器码并执行
-
第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码先编译成机器码,再执行。
-
HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
-
final关键字的作用?
- 被final修饰的类不可被继承
- 被final修饰的方法不可被重写
- 被final修饰的变量不可被修改(final 修饰对象时,只是引用不可变,对象本身的内容依然是可以变化的)
transient关键字的作用?
被transient(临时的)声明的变量,不会写入到序列化文件中去。
父子类的实例化顺序?
-
父类静态变量、静态代码块
-
子类静态变量、静态代码块
-
父类非静态变量、非静态代码块
-
父类构造方法
-
子类非静态变量、非静态代码块
-
子类构造方法
【备注】变量、代码块按照出现的顺序执行
Error和Exception的区别?
- Error是错误,如OutOfMemoryError;Exception是异常,如IOException。
- 他们都继承Throwable类,在java中只有Throwable类才可以被抛出throw或捕获catch异常。
- Error一般是与JVM相关的问题,如系统崩溃、内存不足等,比如OutOfMemoryError。这类错误仅靠程序本身无法恢复;Exception表示程序可以处理的异常。
什么是运行时异常?有哪些常见的运行时异常?
-
Exception可以分为checked exceptions和unchecked exceptions
-
Checked Exception,是指编译阶段会进行检查的异常,即非运行时异常,如:
- IOException
- SQLException
-
Unchecked Exception,是运行时异常,是RuntimeException类及其子类,如:
-
NullPointerException(空指针异常)
-
IndexOutOfBoundsException(下标越界异常)
-
ClassCastException(类转换异常)
-
ArrayStoreException(数据存储异常,操作数组时类型不一致)
-
IO操作的BufferOverflowException异常
-
try-catch-finally中,finally语句一定执行吗?
-
finally的作用就是在发生异常时,可以保证可以执行一些处理策略,一般情况下finally语句都会执行。
-
但也存在特殊场景下不会执行,如:try或catch中执行 System.exit(0); ,退出当前Java虚拟机。
-
线程在执行 try 语句或 catch 语句时被killed
什么是序列化和反序列化?如何实现序列化?
什么是序列化和反序列化?
-
序列化是将对象转换为可传输格式的过程,广泛应用于网络传输。
-
反序列化是序列化的逆操作。
如何实现序列化?
- implements Serializable:类可以通过实现 java.io.Serializable 接口启用序列化功能。
- implements Externalizable:如果一个类中,我们只希望序列化一部分数据,可以其他数据都使用transient修饰,但比较麻烦,这时候可以使用externalizable接口,在writeExternal()方法里定义哪些属性可以序列化。
什么是serialVersionUID?有什么作用?
反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进⾏⽐较, 如果相同就认为是⼀致的, 可以进⾏反序列化, 否则就会出现序列化版本不⼀致异常。
反射
什么是反射?
java的反射机制是指可以在运行状态下获取类和对象的所有属性和方法
反射有什么作用?
-
在运行时创建一个类的对象
-
在运行时获取一个类/对象的成员变量和方法
-
在运行时判断一个对象是否属于一个类
反射的原理?
java类的加载过程:
- 编译:java文件编译后生成.class字节码文件
- 加载:类加载器负责根据类的全限定名来读取此类的二进制字节流到JVM内部,然后将其转换为对应的java.lang.Class对象实例
- 验证:格式(class文件规范) 语义(final类是否有子类) 操作
- 准备:静态变量赋初值和内存空间,final修饰的内存空间直接赋原值,此处不是用户指定的初值。
- 解析:符号引用转化为直接引用,分配地址
- 初始化:有父类先初始化父类,然后初始化自己
Java的反射原理就是利用类加载的第二步:加载,获取到JVM中的class文件,class文件中包含java类的所有信息。
反射有哪些具体的应用场景?
-
JDBC:JDBC连接数据库时,使用Class.forName()通过反射加载数据库的驱动。
-
IOC:Spring的IOC (控制反转) 使用的就是工厂模式+反射的原理,需要使用到的类事先在配置文件中先声明,需要时根据配置的类名动态生成对象。
反射有哪些缺点?
反射代码的执行速度慢,性能差,如果有其他方案可以替代,建议不使用。
如何通过反射创建类对象?
Class.forName("xxx")
try {
clazz = Class.forName("com.reflection.User");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
Class clazz = user.getClass(); // 通过对象的getClass()方法
Class clazz = User.class; // 直接通过类名获取类对象
如何通过反射创建对象?
- 调用无参构造器
try {
clazz = Class.forName("com.reflection.User");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
Object obj = clazz.newInstance();
- 调用有参构造器
try {
clazz = Class.forName("com.reflection.User");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
Constroctor constroctor = clazz.getConstructor(String.class,Integer.class);
Object obj = constroctor.newInstance("龙哥", 29);
IO篇
字节与字符的区别?
- bit,位,最小的二进制单位 ,取值0或者1
- byte,字节,计算机操作数据的最小单位,由8位bit组成,取值 -128~127
- char,字符,16位组成,取值 0~65535
Java IO有哪些类型?
- 按流向分,分为输入流和输出流。
- 按内容分,分为字节流和字符流。
- InputStream/Reader: 所有输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
字节流和字符流的区别?
- 字节流:操作byte类型数据,主要是OutputStream、InputStream的子类;不用缓冲区,直接对文件本身操作。
- 字符流:操作字符类型数据,主要是Reader、Writer的子类;使用缓冲区缓冲字符,不关闭流就不会输出任何内容。
互相转换:
-
OutputStreamWriter:是Writer的子类,将输出的字符流变为字节流。
-
InputStreamReader:是Reader的子类,将输入的字节流变为字符流。
异步和同步、阻塞和非阻塞之间的区别?
- 阻塞、非阻塞:调用方是否等待
- 同步、异步:描述被调用方如何处理请求
- 同步不一定阻塞,异步也不一定非阻塞,没有必然关系。
Liunx的IO模型有哪些?
- 阻塞式IO模型:数据如果没有准备好,调用方等待。
- 非阻塞IO模型:数据如果没有准备好时,调用方不等待。
- IO复用模型:使用一个线程监控多个socket是否有到达事件,只有当socket真正有读写事件时,才会创建线程资源,减少资源占用。
- 信号驱动IO模型:当线程A发起一个IO请求操作,会给对应的socket注册一个信号函数,然后线程A继续执行其他逻辑。当数据就绪时,线程A会收到信号,在信号函数中调用执行IO操作。
- 异步IO模型:当线程发起IO操作,可以开始去做其它的事,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。
BIO、NIO、AIO 的区别?
- BIO:Blocking I/O,同步阻塞 I/O 模式,调用方阻塞等待数据读取/写入的完成。
- NIO:New I/O,或 Non-blocking I/O,同步非阻塞 I/O 模型。
- AIO:Asynchronous I/O,异步非阻塞的 IO 模型,基于事件和回调机制实现。调用方调用I/O后,被调用方会直接返回,不会堵塞在那里;当后台处理完成,操作系统会通知相应的线程进行后续的操作。
select、poll 和 epoll 什么区别?
它们是 NIO 多路复用的三种实现机制,由 Linux 系统提供。
- select:仅知道有 I/O 事件发生,无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作,所以 select 具有 O(n)的无差别轮询复杂度。
- poll:本质上和 select 没有区别
- epoll:epoll 会把哪个流发生了 怎样的 I/O 事件通知我们,所以 epoll 是事件驱动的,复杂度降低到了 O(1)。
什么是Reactor模型?有什么作用?
reactor模型是对传统阻塞I/O服务模型的一种优化
传统阻塞IO模型的特点:
- 采用阻塞模式获取数据
- 每个链接需要独立线程完成数据的输入、处理、返回。
传统阻塞IO模型存在的问题:
- 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read操作,造成线程资源浪费。
- 当并发数很大,会创建大量的线程,占用大量系统资源,OOM概率高
reactor的解决方案:
- 基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。
- 基于线程池复用线程资源:不必为每个连接创建线程,一个线程可以处理多个连接的业务。
主从Reactor模型:
mainReactor只负责连接请求,subReactor只负责处理客户端的读写事件。
什么是netty?有哪些应用场景?工作原理是什么?
什么是netty?
netty是一个基于NIO的网络开源框架,可以单独作为rpc框架使用,也可以作为其他框架的底层通信组件,如:Dubbo的RPC框架底层用使用的是Netty。
netty的应用场景:
-
应用于分布式系统中不同节点之间的通信,即应用于rpc框架,如:dubbo
-
应用于消息系统,如:rocketmq
netty的工作原理:
- I/O复用
-
一个I/O线程可以并发处理N个客户端连接,和读写操作。
-
当线程从某客户端Socket通道,进行读写数据时,若没有数据可用时,该线程可以进行其他任务。
-
线程在空闲时间,可用于在其他通道上执行IO操作,所以单独的线程,可以管理多个输入和输出通道。
- 事件驱动
Netty基于主从Reactors多线程模型:
- MainReactor负责对外的客户端连接请求,并将请求转交给SubReactor。
- SubReactor负责对内的I/O读写请求。
- 具体逻辑处理的任务,会直接进入写入队列,等待worker threads进行处理。
- 高性能网络通信
- 使用心跳机制关闭无效会话
- 启动多个串行化的线程,实现局部无锁化,避免多线程竞争带来的线程切换
- 采用Direct buffers,直接使用堆外内存进行socket读写,减少了内存拷贝。
集合篇
什么是集合?
Map接口和Collection接口是所有集合类的父接口
- Collection接口
-
List:Arraylist、Vector、LinkedList
-
Set:HashSet、LinkedHashSet、TreeSet
- Map接口:HashMap、LinkedHashMap、HashTable、TreeMap
什么是Map?
Map是java的一种集合接口,支持键K和值V的一一映射,提供了一系列操作Key-Value的方法。
Collection和Collections的区别?
-
Collection 是一个集合接口,是list、set等的父接口。
-
Collections 是一个包装类,包含各种操作集合的方法。
Collection如何迭代?
List<String> list = ImmutableList.of("Hollis", "hollischuang");
// 1. 普通for循环遍历
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
// 2. 增强for循环遍历
for (String s : list) {
System.out.println(s);
}
// 3. Iterator遍历
Iterator it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
// 4. Stream 遍历
list.forEach(System.out::println);
list.stream().forEach(System.out::println);
Set和List的区别?
- List、Set都继承自Collection接口,都用来存储一组相同类型的元素。
- List特点:元素有放入顺序,元素可重复。
- Set特点:元素无放入顺序,元素不可重复。
Set如何保证元素不重复?HashSet的原理?TreeSet的原理?
-
HashSet:HashSet底层使用HashMap存储数据,当向HashSet中添加元素的时候,首先计算元素的hashCode值,然后通过扰动计算和按位与的方式计算出这个元素的存储位置,如果这个位置为空,就将元素添加进去;如果不为空,则用equals()方法比较元素是否相等,相等就不添加,否则找一个空位添加。
-
TreeSet:元素在插入TreeSet时元素的compareTo()方法会被调用,以判断是否为重复元素,所以TreeSet中的元素一定要实现Comparable接口,
HashMap的数据结构?
- JDK7及之前,HashMap的内部数据结构是数组+链表。
- JDK8开始,当链表长度 > 8 时会转化为红黑树,当红黑树元素个数 ≤ 6时会转化为链表。
为什么要将链表转化为红黑树?
链表长度太长时,红黑树的存取效率比链表高。
HashMap put元素的原理?
HashMap get元素的原理?
HashMap如何计算K的数组下标?
- 先计算K的hash值:h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
- index = h & (length-1)
为什么HashMap的容量必须是2的幂?
元素的存储位置:index = h & (length-1),2的幂次方-1都是1,可以充分利用高低位特点,减少hash冲突。
HashMap什么时候扩容?
-
当元素数量超过阈值时扩容,阈值 = 数组容量 * 加载因子。
-
数组容量默认16,加载因子默认0.75,所以默认阈值12。
-
容量上限为1 << 30
HashMap的扩容原理?
JDK7扩容原理:
JDK7为什么采用头插法?
刚添加的元素,被访问的概率大。
头插法有什么问题?
多线程下会出现死循环,造成CPU高。
JDK8做了哪些改进?
- 使用尾插法解决死循环问题
- 使用尾插法,在扩容时会保持链表元素原来的顺序,解决了死循环问题,但解决不了线程不安全问题。
- 扩容的效率更高
扩容前 index = hash&(oldTable.length-1)
扩容后:index = hash&(oldTable.length*2-1)
唯一的区别是 length -1 多了一个高位1参与运算,如果hash对应的位置是0,则Node的index没变,如果hash对应位置是1,则newIndex = oldIndex + oldLength。
即得出结论:扩容后,Node的位置要么不变,要么移动odLength。
因此,在扩容时,不需要重新计算元素的hash值了,只需要通过 if ((e.hash & oldCap) == 0) 判断最高位是1还是0就可以确定index,效率更高。
HashMap与HashTable的对比?
**【相同点】**都用于存储K-V元素
【不同点】
-
HashMap可接受null键值和值,Hashtable则不能
-
HashMap线程不安全,HashTable线程安全,HashTable的方法都加了synchronized
-
HashMap继承AbstractMap类,HashTable继承Dictionary类
-
HashMap的迭代器(Iterator)是fail-fast迭代器,HashTable的enumerator迭代器不是fail-fast,所以遍历时如果有线程改变了HashMap(增加或者移除元素),将会抛出ConcurrentModificationException。
-
HashMap默认容量16,扩容为old * 2;HashTable默认容量11,扩容为old * 2+1。
HashMap和HashSet的区别?
HashSet实现Set接口,不允许集合中有重复值;HashMap实现Map接口,存储键值对,K不允许重复。
ConcurrentHashMap
什么是ConcurrentHashMap?
-
HashMap线程不安全,多线程环境可以使用Collections.synchronizedMap、HashTable实现线程安全,但性能不佳。
-
ConurrentHashMap比较适合高并发场景使用。
JDK7数据结构:Segment数组+HashEntry链表数组
ConcurrentHashMap由一个Segment数组构成(默认长度16),Segment继承自ReentrantLock,所以加锁时Segment数组元素之间相互不影响,所以可实现分段加锁,性能高。
Segment本身是一个HashEntry链表数组,所以每个Segment相当于是一个HashMap。
JDK7下put元素原理:
JDK7下get元素原理:
get()操作不需要加锁,是因为HashEntry的元素val和指针next都使用volatile修饰,在多线程环境下,线程A修改Node的val或新增节点时,对线程B都是可见的。
JDK8下数据结构:Node数组+链表/红黑树,类似1.8的HashMap
-
摒弃Segment,使用Node数组+链表+红黑树的数据结构。
-
桶中的结构可能是链表,也可能是红黑树,红黑树是为了提高查找效率。
-
并发控制使用Synchronized和CAS来操作,整体看起来像是线程安全的JDK8 HashMap。
ConcurrentHashMap 和 Hashtable 的区别?
- 线程安全的原理不同:Hashtable 实现并发安全的原理是通过 synchronized 关键字,ConcurrentHashMap利用了 CAS + synchronized + Node 节点。
- 性能不同:Hashtable 每一次修改都要锁住整个对象,ConcurrentHashMap分段锁,并发效率高。
HashSet和TreeSet的区别?
- 都是Set的子类,所以TreeSet和HashSet不可放2个相同的元素
- TreeSet底层是TreeMap实现的,HashSet底层是HashMap实现的
- TreeSet可以确保元素处于排序状态,支持自然排序和定制排序
ArrayList、LinkedList、Vector的区别?
- List实现类主要有:ArrayList、LinkedList与Vector
- ArrayList 是一个可改变大小的数组
- LinkedList 是一个双链表,在添加和删除元素时,有比ArrayList性能更好,但get与set方面弱。
- Vector 和ArrayList很类似,但线程安全,性能差一些。Vector扩容2倍,ArrayList扩容50%。
LinkedHashMap和TreeMap的区别?
- LinkedHashMap 是HashMap的一个子类,基于HashMap和双向链表来实现,保存了元素的插入顺序。
- TreeMap底层使用红黑树的数据结构,对传入的key进行了大小排序,可以使用元素的自然顺序,也可以使用集合中自定义的比较器来进行排序。
什么是Copy-On-Write?
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略。
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
CopyOnWriteArrayList中add/remove等写方法是需要加锁的,目的是为了避免Copy出N个副本出来,导致并发写。
但是,CopyOnWriteArrayList中的读方法是没有加锁,这样做的好处是我们可以对CopyOnWrite容器进行并发的读,当然,这里读到的数据可能不是最新的。因为写时复制的思想是通过延时更新的策略来实现数据的最终一致性的,并非强一致性。
所以CopyOnWrite容器是一种读写分离的思想,读和写不同的容器。而Vector在读写的时候使用同一个容器,读写互斥,同时只能做一件事儿。
多线程篇
线程和进程的区别?
-
进程是操作系统资源分配的基本单位,线程是处理器任务调度和执行的基本单位。
-
同一进程的线程共享本进程的地址空间和资源,不同进程之间的地址空间和资源相互独立
-
一个进程可以包含多个线程(一辆火车可以有多个车厢),线程必须在进程下才能运行(单纯的车厢无法运行)
线程状态有哪些?
- NEW:当创建一个线程时,线程处在 NEW 状态。
- RUNNABLE:运行 Thread 的 start() 方法后,线程进入 RUNNABLE 可运行状态。可运行状态的线程不能马上运行,需要先进入READY就绪状态等待线程调度,在获取CPU时间片 后进入RUNNING运行状态。
- BLOCKED:运行态的线程进入同步代码时,如果获取锁失败,则会进入到 BLOCKED 状态;获取到锁后,会从 BLOCKED 状态恢复到READY就绪状态。
- WAIT:运行中的线程还会进入等待状态,如调用 Object.wait、Thread.join 等
- TERMINATED:线程运行结束
原子性和可见性
什么是原子性?
一系列操作,要么全部发生,要么全部不发生,不会存在中间状态。
什么是JMM? / java的内存模型是什么?
-
java中,所有的共享变量都存储在主内存中
-
每个线程有自己的工作内存,工作内存中保存的是主内存中变量的副本,线程对变量的读写操作只能发生在自己的工作内存中,不能直接读写主内存中的变量。
什么是可见性?
当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
举例:
- 变量 x 在主内存中的值为0
- 线程1想要修改 x ,会先复制主内存中的 x 到自己的工作内存,然后进行修改 x = 1 ,然后立即同步到主内存
- 线程2想要读取 x ,会从主内存中获取到最新的 x 到自己的工作内存,即1个线程的修改会被其他线程读取到。
如何保证内存可见性?
- 使用synchronized关键字加锁
- 使用Lock类加锁
- 使用volatile修饰变量
volatile是如何实现可见性的?
什么是volatile? volatile是 java 中的一个关键字,当变量被 volatile 修饰,对该变量的修改是内存可见的,其他线程可以获取最新的值。
volatile实现内存可见性,依靠的是内存屏障。
什么是内存屏障?
-
CPU有自己的缓存机制(L1、L2、L3),目的是提高性能,避免每次都向内存取,但带来的问题是:不能实时获取最新值。内存屏障是硬件层的概念,本质是一个指令,作用:强制把缓存中的数据写回主内存,让缓存中的数据失效。
-
不同的硬件平台实现内存屏障的手段不一样,java通过 JVM 屏蔽了这些差异,统一生成内存屏障的指令。
什么是java的内存屏障指令?
java的内存屏障有四种,实际际上也是上述两种的组合:
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2操作前,必须保证Load1要读取的数据已被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2操作前,必须保证Store1的写入结果已对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2操作前,必须保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2操作前,必须保证Store1的写入结果已对其它处理器可见。(万能屏障,兼具其它三种内存屏障的功能,但开销也是四种屏障中最大的)
java有哪些常见的锁?
- 可重入锁/非可重入锁
-
可重入锁是指线程已经持有这把锁,可以在不释放锁的情况下,再次获取这把锁。典型的就是ReentrantLock,reentrant 的意思就是可重入。
-
不可重入锁是指,即使线程当前持有了这把锁,但如果想再次获取这把锁,必须先释放锁后才能再次尝试获取。
- 共享锁/独占锁
-
共享锁是指同一把锁可被多个线程同时获得,独占锁是指锁只能同时被一个线程获得。
-
读写锁,很好的诠释了共享锁和独占锁的概念,读写锁中的读锁是共享锁,读锁可以被同时读,可以同时被多个线程持有;写锁是独占锁,写锁最多只能同时被一个线程持有。
- 公平锁/非公平锁
-
公平锁就是先来先得,非公平锁允许插队。
-
为什么需要非公平锁?场景:线程1解锁时,突然线程5尝试获取锁,如果是非公平,线程5可以拿到这把锁,尽管它没有进入等待队列,但从整体系统效率看,锁给线程5效率更快,否则需要执行线程5入队列、去头部线程出现等操作。
- 悲观锁/乐观锁
-
悲观锁是指线程获取资源之前,必须先拿到锁,总是担心有人来抢,所以我先占住。
-
乐观锁不要求线程获取资源前必须拿到锁,利用的是 CAS 理念,认为没人来和自己抢资源。
-
悲观锁和乐观锁的使用场景?悲观锁适用于写多读写,乐观锁适用于写少读多,是一个概率问题。
- 可中断锁/不可中断锁
-
synchronized 修饰的锁是不可中断锁,一旦线程申请了锁,只能等到拿到锁以后才能进行其他的逻辑处理。
-
ReentrantLock 是一种典型的可中断锁,使用 lockInterruptibly 方法在获取锁时,可以在中断之后去做其他的事情,不需要一直等待获取到锁才离开。
请介绍下锁的升级过程
锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁
-
偏向锁:如果锁不存在竞争,只需打个标记,偏向第一个来获取的线程就行。如果尝试获取锁的线程,仍然是偏向锁的拥有者,可直接获得锁,减少开销。
-
轻量级锁:偏向锁被另一个线程访问时,说明存在竞争,偏向锁升级为轻量级锁,线程通过自旋的形式尝试获取锁,不会陷入阻塞。
-
重量级锁:当多个线程有激烈竞争,且锁竞争时间长时,自旋已不能满足需求,轻量级锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态,它是利用操作系统的同步机制实现的,所以开销相对比较大。
总结:偏向锁/轻量级锁/重量级锁,这三种锁特指 synchronized 锁的状态,通过在对象头中的 markword 标识锁的状态。偏向锁性能最好,可避免执行 CAS 操作;轻量级锁利用自旋和 CAS 避免了重量级锁带来的上下文切换;重量级锁则会把获取不到锁的线程阻塞,性能最差。
JVM对锁的优化有哪些?
- 引入锁升级机制:无锁→偏向锁→轻量级锁→重量级锁,提升性能。
- 锁消除:经过逃逸分析后发现,某些对象或方法不会被其他线程访问,只有本线程访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
- 锁粗化:如果释放锁后,又立即重新锁,可以把同步区域扩大,减少加解锁次数。
- 自适应自旋锁:自旋就是不释放 CPU时间片,一直循环尝试获取锁,避免上下文切换。 会根据最近自旋尝试的成功率、锁拥有者的状态等多种因素共同决定自旋次数,减少无用的自旋,提高效率。
请介绍下synchronized的原理?
- 当synchronized 应用在同步代码块上时,进入代码块,需要执行 monitorenter 指令,尝试获取当前对象的锁(monitor对象),monitor进入数为 +1,退出代码块时,执行 monitorexit 指令,进入数 -1。monitor进入数为0时,才可以被其他线程持有。
- 当synchronized 应用在方法上,方法会添加 ACC_SYNCHRONIZED 标志,ACC_SYNCHRONIZED会去隐式调用monitorenter和monitorexit,归根究底,还是monitor对象的争夺。
- monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。
Synchronized 和 ReentrantLock 有什么区别 ?如何选择?
相同点:
- 都是锁,可保护资源线程安全。
- 都具有可重入的特性
不同点:
- 隐式vs显式:synchronized是隐式锁,修饰即可;Lock接口需要显式调用lock()和unlock()。
- 是否可中断:synchronized锁被某个线程获得,其他线程只能被阻塞,如果持有锁的线程永远不释放锁,那么获取锁的线程只能永远等下去。对于Lock类,如果使用的是 lockInterruptibly()方法,可中断退出,也可用 tryLock()方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活。
- 是否公平:ReentrantLock可根据需要设置公平或非公平,默认非公平锁;synchronized 非公平,不能设置。
如何选择synchronized/Lock?
默认使用synchronized,以减少代码,减少出错概率,一旦忘记在finally里unlock,代码可能会出很大的问题。 如果需要特殊功能,如:可中断,才使用 Lock。
什么是CAS和ABA?
什么是CAS?
CAS:Compare and Swap,比较并替换,是乐观锁的一种实现方式。原理:
什么是ABA?
线程T1读取A后,由线程T2改写为B,线程T3又改写为A,此时T1无法 compare 是否发生过修改。
解决方法:版本号、时间戳
ThreadLocal有什么作用?他和synchronized有什么区别?
ThreadLocal的作用是什么?
为线程中的对象创建副本,每个线程都只能修改自己所拥有的副本,而不会影响其他线程的副本,这样就让原本在并发情况下,线程不安全的情况变成了线程安全的情况。
ThreadLocal和synchronized的区别是什么?
ThreadLocal 是每人单独一份(给每个线程都单独给一份资源,线程们不需要去竞争资源),synchronized是一次只能给一人(给一份资源加锁,每次只能有一个线程能访问)。
附加:Thread、 ThreadLocal 及 ThreadLocalMap的关系?
一个 Thread 有一个 ThreadLocalMap,而 ThreadLocalMap 的 key 就是一个个的 ThreadLocal。
fail-fast 和 fail-safe 有什么区别?
- fail-fast
fail-fast,是一种系统设计理念,即先考虑异常的场景,一旦识别该场景,先停止业务,避免发生异常。
public int divide(int divisor,int dividend){
if(divisor == 0){
throw new RuntimeException("divisor can't be null");
}
return dividend/divisor;
}
- 集合类中的fail-fast机制
Java中的fail-fast机制,默认是指Java集合的一种错误检测机制。当多个线程对部分集合进行结构上的改变的操作时,有可能会产生fail-fast机制,这个时候就会抛出ConcurrentModificationException。
- fail-safe
java同时提供fail-safe机制的集合类,这样的集合容器在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
java.util.concurrent包下的容器都是fail-safe的,可以在多线程下并发使用,并发修改。同时也可以在foreach中进行add/remove 。
什么是重排序?
JVM、CPU出于优化处理速度的目的,会对指令的执行顺序进行调整。
举例
- 排序前:
x = 1;
y = 2;
x = x + 1;
- 对应的指令:
Load x
Set to 1
Store x
Load y
Set to 2
Store y
Load x
Set to 2
Store x
- 发生指令重排序:
Load x
Set to 1
Set to 2
Store x
Load y
Set to 2
Store y
- 排序后:
x = 1;
x = x + 1;
y = 2;
happens-before
为什么需要happens-before规则?
因为JVM会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发安全性的影响,需要happens-before规则定义一些禁止编译优化的场景
有哪些happens-before规则?
- 线程的顺序性规则:一个线程中,前面的操作happens-before后续的任何操作。
- volatile规则:对一个volatile变量的写操作 happens-before 后续对这个变量的读操作。
- 传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C。
常见的线程池有哪些?
- FixedThreadPool:固定线程数的线程池,核心线程数和最大线程数一样。
- CachedThreadPool:可缓存线程池,corePoolSize为0,maxPoolSize为 Integer.MAX_VALUE(2^31-1)。
- ScheduledThreadPool:支持定时任务或周期性任务
- SingleThreadExecutor:单线程线程池,适用于任务需要按提交顺序执行的场景。如果发生异常,会重新创建一个线程。
线程池的主要参数有哪些?
- corePoolSize:核心线程数,即常驻线程池的线程数量。
- maxPoolSize:核心线程数无法满足需求时,会增加线程,但有最大线程数限制。
- keepAliveTime:当线程数多于核心线程数时,且任务队列为空,空闲线程在keepAliveTime时间后就会被销毁,以减少资源占用。
- ThreadFactory:线程工厂,可选择使用默认的线程工厂,也可以选择自己定制线程工厂,以方便给线程自定义命名。
- workQueue:存放任务的队列
- Handler:处理被拒绝的任务
线程池有哪些拒绝策略?
- AbortPolicy:抛异常,由业务逻辑选择重试或放弃。
- DiscardPolicy:直接丢弃,无通知,存在一定的风险。
- DiscardOldestPolicy:丢弃任务队列中存活时间最长的任务。
- CallerRunsPolicy:由提交任务的线程执行任务,这样该线程就不会再提交新的任务。
为什么不推荐jdk提供的线程池,而是自己创建线程池?
JDK4种线程池的缺点:
- Executors.newFixedThreadPool():使用的任务队列是LinkedBlockingQueue,队列容量无上限,如果发生大量堆积的任务,会占用大量内存,发生 OOM,影响到整个程序。
- Executors.newSingleThreadExecutor():使用的也是LinkedBlockingQueue
- Executors.newCachedThreadPool():线程数量无上限,可能会创建非常多的线程,导致内存不足。
- Executors.newScheduledThreadPool():任务队列是 DelayedWorkQueue,是一个无界队列,如果队列中存放过多的任务,可能导致 OOM。
总结:jdk提供的几种线程池,都存在资源耗尽的风险,自己手动创建会更好,明确线程数量、任务队列类型、拒绝策略,避免资源耗尽,适应业务场景。
AQS
为什么需要AQS?
如果锁被获取了,还有很多其他线程要来获取锁,总不能给全部拒绝了,这时候就需要他们排队,就需要一个队列。
AQS的原理
通过一个volatile修饰的int属性state代表同步状态,0是无锁状态,1是上锁状态。多线程竞争锁时,通过CAS的方式来修改state,例如从0修改为1,修改成功的线程即为资源竞争成功的线程,竞争失败的线程会被放入一个FIFO的队列中并挂起休眠,当锁被释放时,会从队列中唤醒线程继续工作。
JVM篇
为什么不直接解释源码,而是先把java代码转化为字节码,用JVM执行字节码?
-
实现跨平台:C++直接编译成机器码,由操作系统运行,而JAVA是先编译成字节码,由JVM解释运行,这个环节比C++增加了性能消耗,目的是为了JAVA可以在不同的操作系统上通过不同的虚拟机运行,也是 JAVA 里所说的一次编译到处运行的道理。而且现在JVM的性能已经有了飞跃的改进,在性能上也与 C++不相上下。
-
更加安全:JVM相当于运行环境的隔离
请描述下JVM的整体架构
JVM的内存结构?
线程共享:
-
堆:存放对象;根据对象存活周期的不同,进行分代管理,由垃圾回收器对对象进行回收。
-
方法区:存储被JVM加载的类信息、常量、静态变量等。
线程私有:
-
java方法栈:线程在执行每个方法时,都会同时创建一个栈帧,用来存储局部变量表、方法出口等信息,调用方法时执行入栈,方法返回时执行出栈。
-
本地方法栈:与java方法栈栈类似,用于执行native方法。
-
程序计数器:保存当前线程所执行的字节码位置,每个线程工作时都有一个独立的计数器。
什么是方法区?元数据区、永久代、方法区是什么关系?
-
方法区是JVM规范中的一种概念,该区域用于存储类的相关信息,JDK7中的永久代、JDK8中的 Metaspace元空间都是方法区的一种实现。
-
永久代在堆内,容易发生OOM,特别是动态生成类的代码,比较容易出现永久代内存溢出:“java.lang.OutOfMemoryError: PermGen space”。元数据区存储在本地内存,内存空间大一些,所以JDK8开始, HotSpots取消了永久代,使用元空间实现JVM规范中的“方法区”。
new对象的过程?
-
判断是否需要加载类:检查常量池中是否能定位到该类的符号引用,检查类是否已被加载、解析和初始化,如果没有,执行类的加载。
-
分配内存: 在堆中划分一块区域,为新对象分配内存
-
填充对象头:哪个类的实例、对象的哈希码、对象的GC分代年龄等信息
-
调用对象的构造方法,传入属性进行初始化
-
在栈中新建对象引用 ,指向堆中刚刚新建的对象实例。
有哪四种引用?
-
强引用:GC永远都不会回收强引用的对象,内存空间不足时,宁愿抛出OutOfMemoryError。
-
软引用:只有在内存空间不足时会考虑回收
-
弱引用:不管内存空间够不够,都会回收它。
-
虚引用:不会影响对象的生存时间,目的跟踪对象的回收情况,虚引用对象被GC时会收到一个系统通知。
类的加载过程?
-
加载
- 通过一个类的全限定名来获取定义类的二进制流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
-
验证:确保 Class 文件的字节流中包含的信息符合当前虚拟机要求
-
准备:为类分配内存并设置类变量初始值
-
解析:虚拟机将常量池内的符号引用替换为直接引用的过程
-
初始化:开始执行类中的 java 代码
有哪些类加载器?
- BootStrap 启动类加载器:加载核心的类库(java.lang.*、JVM_HOME/lib)
- 扩展类加载器:加载jre/lib/ext目录下的一些扩展jar
- 应用加载器:自定义加载器的父类,负责加载classpath下的类文件,开发中所写的java文件、引入的jar包都由此加载器加载
- 自定义类加载器
什么是双亲委派机制?如何打破?
什么是双亲委派机制?
类加载器加载类时,会先把加载请求委托给父加载器去执行,如果父加载器还存在父加载器,就继续向上委托,直到顶层的启动类加载器。如果父加载器能够完成类加载,就成功返回,如果父加载器无法完成加载,子加载器才会尝试自己去加载。
作用:避免类的重复加载
如何打破双亲委派机制?
-
自定义类加载器,继承ClassLoader类,重写loadclass()方法:双亲委派机制依靠loadclass()方法实现,可以改写规则
-
使用线程上下文类加载器: JVM支持当前线程使用 线程上下文类加载器 直接加载类
什么是GC?在哪里GC?
-
GC:Garbage Collection,是指java的垃圾收集机制,是JVM管理内存的一种机制。
-
GC发生在堆区
年轻代、老年代和永久代的区别?
-
年轻代:存放新创建的对象
- 年轻代分为 Eden 区和两个 Survivor 区,大部分对象在 Eden 区中生成。当 Eden 区满时,还存活的对象会在两个 Survivor 区交替保存。
- Eden和Survivor比例:8:1:1
-
老年代:存放从年轻代晋升而来的对象
-
永久代:保存类信息
如何判断对象是否可被回收?
- 引用计数法
-
给每个对象一个引用计数器,每当有一个地方引用它时,计数器就会加1;当引用失效时,计数器的值就会减1。计数器值为0时,回收对象。
-
优点:实现简单,可以即时回收对象。
-
缺点: 维护引用计数器需要消耗一定性能;循环引用无法回收(A→B,B→A,计数器不为0,但其实没人再使用它们)。
-
Java没有使用引用计数法,但python有使用到它。
- 可达分析法
-
如果对象到GC Roots之间没有任何引用链相连(不可达),对象可回收
-
主流的开发语言都使用这种方式判断对象是否存活
可以作为GC Roots的对象有哪些?
-
虚拟机栈、本地方法栈中引用的对象
-
方法区中类静态属性、常量引用的对象
JVM如何进行GC?
当Eden区内存不够时,会触发 MinorGC ,对新生代区进行一次垃圾回收。由于每次GC都有大量对象死去,只有少量存活,选用复制算法比较合理,具体过程:
- 把Eden和ServivorFrom存活的对象复制到ServicorTo区域,同时把这些对象的年龄+1
- 如果对象的年龄达到了老年代标准 / ServicorTo内存不够,则赋值到老年代。
- 清空 eden、servicorFrom
- ServicorTo和ServicorFrom互换
新生代的对象晋升入老年代,并导致老年代空间不足时才触发 MajorGC,采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。
对象的内存结构是什么?
在 HotSpot 虚拟机中,对象的内部分为 3 块区域:对象头、实例数据和对齐填充。
- 对象头
对象头(Header)包含三部分:
- Mark Word,存储对象的运行时数据,包括对象的哈希码、GC分代年龄、锁状态标志偏向线程ID等。
- 类型指针:JVM通过这个指针确定对象是哪个类的实例
- 数组长度:可选,只有对象是数组时才有
- 实例数据:实例数据(Instance Data),程序中定义的字段内容
- 对齐填充:保证对象大小是字节的整数倍,减少堆内存的碎片空间
JVM什么情况下会发生栈内存溢出?
- StackOverflowError
栈是线程私有的,它的生命周期与线程相同。每个方法在执行的时,会创建一个栈帧,用来存储局部变量表、方法出口等。如果线程所请求的栈内存大于虚拟机所允许的最大内存,将抛出 StackOverflowError 栈内存溢出异常 一般在方法递归调用时会发生栈内存溢出。
- OutOfMemoryError
栈内存没有超过JVM限制,但操作系统已没有足够的内存可以分配给JVM使用。
常用的 jvm 调优参数都有哪些?
- -Xms:初始堆内存256M
- -Xmx:最大堆内存2G
- -Xmn:新生代大小50M
- -Xss:线程的栈大小
- -XX:+PrintGCDetails:打印 gc 详细信息
- -XX:+HeapDumpOnOutOfMemoryError:发生OutOfMemoryError时,生成dump堆快照
当JVM出现了内存溢出,怎么排查?
-
获取dump文件:jmap -dump:format=b,file=F:/heamdump.out 16540
-
利用MAT工具分析dump文件,查看占用内存高的线程、对象
有哪些常见的垃圾回收算法?
- 引用计数法
通过对象被引用的次数来确定对象是否被使用
缺点:无法解决循环引用的问题
- 复制算法
需要 from 和 to 两块相同大小的内存空间,轮流使用两块内容区域。
缺点:内存使用率低
- 标记清除法
先标记,再清除。
缺点:产生内存碎片
- 标记整理法 标记 - 清除 - 整理
缺点:执行效率低
- CMS算法
最主流的垃圾回收算法。优点:并发收集,停顿小。
回收流程:
-
第一阶段:初始标记,stop the world,标记的对象只是从 root 集直接可达的对象。
-
第二阶段:并发标记,标记可达的对象。
-
第三阶段:重新标记,stop the world,重新扫描并标记。
-
第四阶段:并发清理
-
最后阶段:并发重置,为下一次 GC 重置相关数据结构。
- G1算法
G1是JDK9后,JVM的默认垃圾回收算法。
G1将堆划分为若干个区域,称作Region,一部分区域用作年轻代,一部分用作老年代,还有一种专门用来存储巨型对象的区域。
G1每次只清理一部分,而不是全部的 Region,所以每次GC停顿时间不长。
字符串常量池
什么是字符串常量池?
String Pool实质是一个StringTable类,它是一个Hash表,作用是避免字符串的重复创建,提升性能和减少内存开销。
JDK7之前,String Pool只存放字符串常量;JDK7及之后版本,String Pool也可以存放堆内字符串对象的引用。
字符串常量池在哪里?
JDK7之前,字符串常量池在Perm Gen区,即永久代。(方法区)
JDK7及之后版本,字符串常量池移到了堆中了,内存区域更大一些。
设计模式
单例模式
什么是单例模式?有什么作用?具体的应用场景是什么?
什么是单例模式?
单例模式可以保证一个类只有一个实例,同时提供一个可以全局访问的入口。
单例模式的作用?
- 保证多线程下的线程安全
- 避免重复创建,节省内存
单例的应用场景?
- 创建一个对象需要消耗的资源很多
- 需要定义大量的静态常量和静态方法,比如工具类
单例模式有哪些写法?各有什么优缺点?
- 饿汉写法:先创建对象再说
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton() { }
public static synchronized Singleton getInstance() {
return singleton;
}
}
- 优点:初始化时就已经创建好唯一的对象,天然不存在线程安全问题。
- 缺点:类加载时就会初始化单例,对象不一定会使用,浪费内存。
- 懒汉写法:需要时再创建对象
public class Singleton {
private static Singleton singleton;
private Singleton() { }
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
- 优点:需要时才会创建对象,避免资源浪费
- 缺点:锁直接加在了方法上,每一次的访问都需要获取锁,性能低。
- 双重检查锁写法
public class Singleton {
private static volatile Singleton singleton; // 使用volatile可以禁止重排序
private Singleton() { }
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
优点:只有 instance == null时才加锁,性能高
双重检查锁单例模式中,为什么要用volatile关键字?
因为 singleton = new Singleton(); 并不是原子操作,内部包含三步:
- 分配内存空间
- 调用构造函数初始化singleton
- 将对象指向内存空间
这3步如果发生了指令重排序,在多线程下会出现逻辑异常,使用volatile可以禁止指令重排序,确保多线程下运行正常。