常见面试题目
最后更新于
Java内存区域
整理了n多遍了,但是由于JVM规范是相对宽松的,HotSpot虚拟机并不是一板一眼的按照JVM推荐进行实现,加上版本的更迭,难免会出现混乱的情况。
继续来总结一遍吧,总是喜欢考
线程私有的:
程序计数器,用于存储下一条需要执行指令的位置,可以用于控制程序的执行,如顺序执行,循环执行,还可以用来在线程发生上下文切换时候记录下该线程再次分配到CPU时间片的时候应该执行什么指令,从哪儿开始执行
Java虚拟机栈,也就是我们常说的栈,用于存放方法执行的栈帧,每进行一次方法调用就会进行一次栈帧的入栈操作,栈帧中存储有局部变量表,方法出口等信息
本地方法栈:类似于Java虚拟机栈,只不过这里是存储的本地方法执行后的栈帧,HotSpot虚拟机将其融合到了Java虚拟机栈当中去,这里提出的本地方法栈是JVM给出的一个规范,并未强制要求虚拟机如何实现
线程共享的:
堆:用于存储对象实例,几乎所有的对象实例都是直接在堆上的,是GC回收的主要区域,因此也被称为GC堆,更进一步的划分可以分为新生代和老年代,新生代又可以被划分为一个Eden和两个Survivor,比例为8:1
方法区:存放虚拟机加载的类信息,常量,静态变量,即时编译器编译出的代码等信息,在Java6时候的HotSpot的实现为永久代,永久代是直接占据部分堆内存,因此可能造成内存泄漏等问题,在Java8时候的实现为元空间,占据的是直接内存,减少了内存泄露问题出现的可能性,同时不受Java堆大小的控制,减少发生OOM的概率
运行时常量池跟随方法区移动,属于方法区的一部分
字符串常量池则一直处在堆当中
直接内存:并不在JVM规范当中,但也频繁的被使用到,如1.4引入的NIO的Buffer就使用到了直接内存,主要是避免了系统从用户态到核态的频繁切换带来的开销,还有就是Native堆和JVM堆的数据来回复制开销。
当支持动态扩展的时候报StackOverflowError,不支持动态扩展且达到了最大内存报OutOfMemoryError
对象的创建过程
1、到方法区中查看这个类是否被加载过,如果没有被加载过就去加载
2、如果类加载检查通过后就可以在堆中划分一块内存来存储对象了,具体大小在读取类信息的时候就已经定了
对内存的分配则取决于JVM的垃圾收集器了
(内存规整的情况)可能通过指针碰撞的方式:这时候的堆内存模型为一边是使用过的内存,一边是没有使用过的内存,中间由指针隔开,此时分配内存只需要拨动指针,完成对象内存的分配即可
(内存不规整的情况)此时的内存模型为JVM维护一个可用队列记录着可用内存快的大小,从中分配一块大小近似的即可。
Java在创建对象的时候需要保证是线程安全的且高效的,采用了初始化锁的机制,实现为CAS,主要通过判断预分配的Eden内存是否改变了来判断是否有线程安全问题
3、初始化零值
完成内存分配后,开始将分配到的内存数据清零,这样可以保证在不赋值的情况下基本数据类型使用默认值,引用类型为null
4、设置对象头,完成对象头信息的设置,如:对象的类信息(指针),哈希码,锁的状态,GC年代信息等等
5、执行初始化方法,主要包含构造函数和代码块的执行
对象的内存布局:对象头、实例数据和填充对齐,GC年代
对象头中包含:对象的类信息(指针指向)、哈希码、MarkWord信息
实例数据:对象的有效信息如定义的各种非静态字段
对齐填充:更利于对象的管理,要求对象占用内存为8字节的整数倍
对象的访问方式:
主要有两种:句柄和指针
句柄就是对象的指针的指针,各有优劣,Java使用指针访问,速度快,句柄的优势是在于它的稳定性,只需要改变对象的指针即可,句柄信息不用改变。
字符串常量的简单回顾
主要就这几种形式:第一种直接赋值,在字符串常量池中获取,如果没有则创建,返回一个指向字符串常量池的指针,第二种而是创建一个或者两个对象,取决于字符串是否存在于字符串常量池当中,返回指向堆的一个引用,第三种是返回对象的字符串常量池引用,如果没有则创建(一般都是有的,因为都已经存在一个字符串引用了)。
至于其他的基本数据类型的常量池,则是直接存储于方法区当中的,可以在valueof方法中看到
一般(Byte,Short,Long,Integer)都是-128到127,char为0到127,Boolean为TRUE和FALSE,float和Double则没有。
JVM的常见运行参数指定:
-Xms
:堆的最小内存,也就是初始化堆内存
-Xmx
:堆的最大内存
-XX:NewSize
:新生代内存的最小值,也就是初始化新生代内存大小
-XX:MaxNewSize
:新生代内存最大大小
如果不想让新生代扩容,可以指定两个值为一样的,也可以使用-Xmn
-XX:NewRatio
:新生代和老年代的比值
-XX:MetaspaceSize
:元空间的初始化大小
-XX:MaxMetaspaceSize
:元空间最大的大小
GC调优思路:到万不得已的时候才会进行GC调优,一般都是通过GC日志信息来分析Java代码哪里写的不合理,从而解决问题。目的是将转移到老年代的对象数量降至最低,减少GC的执行时间
操作的对象:Java堆
经过一次垃圾回收,存活对象的GC年代信息加一,直到达到一个阈值进入老年代,小对象优先分在Eden区,大对象直接进入老年区。
当Eden区没有空间时候发生一次Minor GC。
GC的分类:
部分收集(Partial GC)
新生代收集(Minor/Young GC):只对新生代进行收集
老年代收集(Major/Old GC):只对老年代进行收集
混合收集(Mixed GC):对新生代和老年代进行收集
整堆收集(Full GC):整个堆和方法区
要收集哪一些对象?
收集一些无效的对象,两种方法:引用计数法和可达性分析
引用计数法:当对象被引用,引用计数器+1,取消引用就减一。效率高,但是没被使用,因为无法解决循环引用这个问题。
可达性分析:从GC Roots为起点向下搜索,进行对象的可达性分析,不可达的对象会被回收掉
可以作为GC Roots的对象:
虚拟机栈和本地方法栈中本地变量表所引用的对象(方法中的引用)
方法区中类静态属性引用的对象(类中的静态属性)
方法区中常量引用的对象(类中的final属性)
引用类型
强引用——软引用——弱引用——虚引用
垃圾收集器绝对不会回收强引用
垃圾收集器在空间足够的时候不会回收软引用,但是在缺乏空间的时候会进行回收
垃圾收集器只要扫描到弱引用就会回收(ThreadLocalMap中的Key使用的就是弱引用)
虚引用顾名思义和形同虚设一样,如果一个对象持有虚引用,就和没有任何引用是一样的,虚引用主要是用来跟踪对象被垃圾回收的活动
被标记为不可达对象不一定立马就会被回收,还会执行finalize方法(如果覆盖了的话),此时如果对象可以通过finalize方法重新连接到GC Roots上,那么就不会被回收
垃圾收集算法:标记-清除、复制算法、标记-整理、分代收集算法
标记-清除算法:标记需要清理的然后就地进行清理,问题:会产生大量的空间碎片
复制算法:只使用其中一半,发生GC时候将存活对象移动到另一半,问题:效率低(需要对对象进行大量复制) 2、资源利用率低(50%)
标记-整理算法:标记存活对象,将其挪至内存一端,清理剩下的空间。
分代收集算法:就是对每一个地方使用不同的算法,如在新生代中最终只会有少量对象存活,复制成本低,可以使用复制算法,老年代的存活率高,我们需要使用标记-整理算法来腾出空间来存放对象
垃圾收集算法实现:垃圾收集器
垃圾收集器名字 | 是否会STW | 是否是单线程 | 收集算法 | 特性 |
Serial | 会一直STW直到垃圾收集结束 | 是 | 标记-整理 | 历史最悠久的收集器 |
ParNew | 会一直STW直到垃圾收集结束 | 否 | 新生代采用复制算法,老年代采用标记-整理算法 | 就是Serial的多线程版本 |
Parallel Scavenge | 会一直STW直到垃圾收集结束 | 否 | 新生代采用复制算法,老年代采用标记-整理算法 | 强调吞吐量 |
Serial Old | 会一直STW直到垃圾收集结束 | 是 | / | Serial老年代版本 |
Parallel Old | 会一直STW直到垃圾收集结束 | 否 | / | Parallel Scavenge老年代版本 |
CMS | 较其他来说短 | 完全是(基本上实现了垃圾收集器线程与用户线程的并行) | 标记-清除算法 | 强调停顿时间 |
G1 | 较其他来说短,比CMS长 | 完全是 | 去除老年代的概念,将内存划分为一个个Region,每次回收最有价值的Region | 时间段,吞吐量高,Java9的默认收集器 |
JDK的常见工具(在bin目录下)
常见工具(在bin文件夹下):
jps:类似于Linux下的ps命令,查看所有Java进程
jstat:收集HotSpot的运行数据
jinfo:显示虚拟机配置信息
jmap:生成堆快照
jstack:生成线程快照
基本上都是照搬《深入理解Java虚拟机》这本书的了,看自己笔记就行
un表示n个字节
名称 | 解释 | 占位 | 数量 |
magic | 魔数,以此确定是否为class文件 | u4 | 1 |
minor_version | 副版本号 | u2 | 1 |
major_version | 主版本号 | u2 | 1 |
constant_pool_count | 常量池计数器,后面常量池的元素个数 | u2 | 1 |
constant_pool | 常量池,其中存放cp_info | cp_info | constant_pool_count |
access_flags | 访问标志,查看当前class是类还是接口,是否标注了final,abstract等 | u2 | 1 |
this_class | 该类的全限定名 | u2 | 1 |
super_class | 父类的全限定名 | u2 | 1 |
interfaces_count | 实现接口的数量 | u2 | 1 |
interfaces | 具体实现接口的全限定名,顺序为implement的顺序 | u2 | interfaces_count |
fields_count | 包含静态变量和非静态变量的个数 | u2 | 1 |
fields | 存放一个个field_info | field_info | fields_count |
methods_count | 包括静态方法和非静态方法的个数 | u2 | 1 |
methods | 存放一个个method_info | method_info | methods_count |
attributes_count | 属性表数量 | u2 | 1 |
attributes | 属性表用于存放前面的字段表和方法表的信息 | attributes_info | attributes_count |
:zap:类加载过程
这是整个类的生命周期,只有前面的加载——连接——初始化属于类加载过程
第一步:加载
1、通过全类名获取此类的二进制文件流(ZIP也行,也就是后面的JAR、WAR基础)
2、将字节流所代表的静态存储结构转换为方法区的运行时数据结构
3、在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口
简而言之:将class文件从硬盘上转入内存中,创建一个Class实例化对象指向这片内存
我们可以通过重写类加载器的loadclass方法来实现类的加载
会涉及到类加载器,双亲委派模型的知识点,明天再总结
2、连接
加载和连接过程往往是交叉进行的,加载阶段尚未结束可能连接阶段已经开始了
分为三个阶段,三个阶段也是穿插执行,如验证阶段会验证解析的结果是否符合规范
1、验证:主要验证class文件格式符合Java虚拟机规范(魔数,版本号等信息),保证class运行后不会损坏JVM自身,会验证解析阶段将符号引用转换为直接引用的时候的校验
2、准备:为静态变量分配空间并执行置零操作,如果变量被final修饰则会直接置为指定的值
3、解析:将常量池中的符号引用(相当于偏移量)转换为直接引用(转换为直接地址)的过程,主要是针对类,接口,字段,方法的符号引用的转换
3、初始化
类加载的最后一步,类的初始化,主要是init方法的调用
类的初始化也有点懒加载的味道了,只有在以下几种情况下对类进行初始化(很少见的词汇了,因为JVM规范一般没有做如此严格的限定):
1、第一次执行new的时候
2、访问类的静态变量的时候
3、给类的静态变量赋值
4、调用类的静态方法
5、初始化一个子类,如果父类没有初始化需要进行初始化
6、默认主类会被初始化
4、卸载
不在类加载过程当中了,卸载等价于Class对象被GC了
需要同时满足如下三个需求:
1、所有实例对象已经GC
2、Class没有在任何其他地方被引用
3、类加载器已经被GC
因此,出现类被GC的情况很少,因为JDK自带的ClassLoader是一定不会被GC的,必不满足第三条,因此通常只有我们自定义的类加载器加载的类可能发生GC。
类加载的三个步骤:加载——连接(验证、准备、解析)——初始化
类加载器的作用就是将.class文件加载到内存当中
JVM内置的三个类加载器:
BootstrapClassLoader(启动类加载器),不在JDK 中,是使用C++实现的,加载JAVA_HOME/lib
目录下的jar包或者是class文件
ExtensionClassLoader(扩展类加载器),主要负责加载JRE_HOME/lib/ext
目录下的JAR包和class文件的加载
AppClassLoader(应用程序类加载器),负责加载当前classpath下的jar包和class文件
双亲委派模型:
得先从类加载器说起,JVM内置有三层类加载器,这三层之间不是通过继承来实现功能复用的,而是通过组合的方式实现功能复用的,最上层的BootStrapClassLoader是使用C++来实现的,主要加载JAVA_HOME/lib下的JAR包和class文件,第二层的ExtensionClassLoader是第二层,负责对JRE_HOME/lib/ext的JAR包和class文件进行加载,第三层的AppClassLoader负责加载classpath下的JAR包和class文件。我们自己实现的类加载器就都处于第四层,自定义类加载规则,当需要加载类的时候,会先从下往上进行查找是否加载过了,如果加载过了就直接返回,如果没有加载就自上而下地尝试去加载类。
这里的双亲更多指的是父辈与子辈之间的关系
使用双亲委派模型可以保证JVM的稳定,类不会被重复加载。
自定义类加载器,继承classLoader,如果不想打破双亲委派模型,需要实现ClassLoader类中的findClass方法,如果想要打破双亲委派模型,重写loadClass方法。
因为loadclass方法里面存在着双亲委派模型的代码,在其中会调用findclass,相当于是一种模板模式,如果我们覆盖了loadclass的默认实现就破坏了双亲委派模型。
主要是volatile的弊端,而JUC包里面的核心AQS使用到了volatile,所以RentrantLock也会存在这个问题,面试时候可以着重回答。
总线风暴具体的体现就是总线带宽飙升
每个线程都有一个自己的工作内存,为了保证工作内存的变量的可见性,引入了缓存一致性协议,而其中的MESI(Intel提出的)就是其中的佼佼者,每个线程需要通过嗅探器来保证volatile变量是否过期,在频繁修改CAS和volatile的时候就会产生大量的消息,从而导致总线带宽飙高,总线风暴问题产生。