转载自:https://github.com/crossoverJie/Java-Interview
1. Java 运行时的内存划分
1.1 程序计数器
记录当前线程所执行的字节码行号,用于获取下一条执行的字节码。
当多线程运行时,每个线程切换后需要知道上一次所运行的状态、位置。由此也可以看出程序计数器是每个线程私有的。
1.2 虚拟机栈
虚拟机栈是有一个一个的栈帧组成,栈帧是在每一个方法调用时产生的。
每一个栈帧由局部变量区、操作数栈等组成。每创建一个栈帧压栈,当一个方法执行完毕之后则出栈。
如果出现方法递归调用出现死循环的话就会造成栈帧过多,最终会抛出 stackoverflow 异常。
这块内存区域也是线程私有的。
1.3 Java 堆
Java 堆是整个虚拟机所管理的最大内存区域,所有的对象创建都是在这个区域进行内存分配。
这块区域也是垃圾回收器重点管理的区域,由于大多数垃圾回收器都采用分代回收算法,所有堆内存也分为 新生代、老年代,可以方便垃圾的准确回收。
这块内存属于线程共享区域。
1.4 方法区
方法区主要用于存放已经被虚拟机加载的类信息,如常量,静态变量。 这块区域也被称为永久代。
1.5 运行时常量池
运行时常量池是方法区的一部分,其中存放了一些符号引用。当 new 一个对象时,会检查这个区域是否有这个符号的引用。
2. 类的加载机制
2.1 双亲委派模型
模型如下图:
双亲委派模型中除了启动类加载器之外其余都需要有自己的父类加载器
当一个类收到了类加载请求时: 自己不会首先加载,而是委派给父加载器进行加载,每个层次的加载器都是这样。
所以最终每个加载请求都会经过启动类加载器。只有当父类加载返回不能加载时子加载器才会进行加载。
双亲委派的好处 : 由于每个类加载都会经过最顶层的启动类加载器,比如 java.lang.Object这样的类在各个类加载器下都是同一个类(只有当两个类是由同一个类加载器加载的才有意义,这两个类才相等。)
如果没有双亲委派模型,由各个类加载器自行加载的话。当用户自己编写了一个 java.lang.Object类,那样系统中就会出现多个 Object,这样 Java 程序中最基本的行为都无法保证,程序会变的非常混乱。
3. 垃圾回收
垃圾回收主要思考三件事情:
- 哪种内存需要回收?
- 什么时候回收?
- 怎么回收?
3.1 对象是否存活
引用计数法
这是一种非常简单易理解的回收算法。每当有一个地方引用一个对象的时候则在引用计数器上 +1,当失效的时候就 -1,无论什么时候计数器为 0 的时候则认为该对象死亡可以回收了。
这种算法虽然简单高效,但是却无法解决循环引用的问题,因此 Java 虚拟机并没有采用这种算法。
可达性分析算法
主流的语言其实都是采用可达性分析算法:
可达性算法是通过一个称为 GC Roots 的对象向下搜索,整个搜索路径就称为引用链,当一个对象到 GC Roots 没有任何引用链 JVM 就认为该对象是可以被回收的。
如图:Object1、2、3、4 都是存活的对象,而 Object5、6、7都是可回收对象。
可以用作 GC-Roots 的对象有:
- 方法区中静态变量所引用的对象。
- 虚拟机栈中所引用的对象。
3.2 垃圾回收算法
标记-清除算法
标记清除算法分为两个步骤,标记和清除。 首先将需要回收的对象标记起来,然后统一清除。但是存在两个主要的问题:
标记和清除的效率都不高。
清除之后容易出现不连续内存,当需要分配一个较大内存时就不得不需要进行一次垃圾回收。
标记清除过程如下:
复制算法
复制算法是将内存划分为两块大小相等的区域,每次使用时都只用其中一块区域,当发生垃圾回收时会将存活的对象全部复制到未使用的区域,然后对之前的区域进行全部回收。
这样简单高效,而且还不存在标记清除算法中的内存碎片问题,但就是有点浪费内存。
在新生代会使用该算法。
新生代中分为一个 Eden 区和两个 Survivor 区。通常两个区域的比例是 8:1:1 ,使用时会用到 Eden 区和其中一个 Survivor 区。当发生回收时则会将还存活的对象从 Eden ,Survivor 区拷贝到另一个 Survivor 区,当该区域内存也不足时则会使用分配担保利用老年代来存放内存。
复制算法过程:
标记整理算法
复制算法如果在存活对象较多时效率明显会降低,特别是在老年代中并没有多余的内存区域可以提供内存担保。
所以老年代中使用的时候分配整理算法,它的原理和分配清除算法类似,只是最后一步的清除改为了将存活对象全部移动到一端,然后再将边界之外的内存全部回收。
分代回收算法
现代多数的商用 JVM 的垃圾收集器都是采用的分代回收算法,和之前所提到的算法并没有新的内容。
只是将 Java 堆分为了新生代和老年代。由于新生代中存活对象较少,所以采用复制算法,简单高效。
而老年代中对象较多,并且没有可以担保的内存区域,所以一般采用标记清除或者是标记整理算法。
4. OOM 分析
4.1 Java 堆内存溢出
在 Java 堆中只要不断的创建对象,并且 GC-Roots 到对象之间存在引用链,这样 JVM 就不会回收对象。
只要将-Xms(最小堆),-Xmx(最大堆) 设置为一样禁止自动扩展堆内存。
当使用一个 while(true) 循环来不断创建对象就会发生 OutOfMemory,还可以使用 -XX:+HeapDumpOutofMemoryErorr 当发生 OOM 时会自动 dump 堆栈到文件中。
伪代码:
1 | public static void main(String[] args) { |
当出现 OOM 时可以通过工具来分析 GC-Roots 引用链 ,查看对象和 GC-Roots 是如何进行关联的,是否存在对象的生命周期过长,或者是这些对象确实改存在的,那就要考虑将堆内存调大了。
1 | Exception in thread "main" java.lang.OutOfMemoryError: Java heap space |
java.lang.OutOfMemoryError: Java heap space表示堆内存溢出。
4.2 MetaSpace (元数据) 内存溢出
JDK8 中将永久代移除,使用 MetaSpace 来保存类加载之后的类信息,字符串常量池也被移动到 Java 堆。
PermSize
和 MaxPermSize
已经不能使用了,在 JDK8 中配置这两个参数将会发出警告。
JDK 8 中将类信息移到到了本地堆内存(Native Heap)中,将原有的永久代移动到了本地堆中成为 MetaSpace ,如果不指定该区域的大小,JVM 将会动态的调整。
可以使用-XX:MaxMetaspaceSize=10M
来限制最大元数据。这样当不停的创建类时将会占满该区域并出现 OOM。
1 | public static void main(String[] args) { |
使用 cglib 不停的创建新类,最终会抛出:
1 | Caused by: java.lang.reflect.InvocationTargetException |
注意:这里的 OOM 伴随的是 java.lang.OutOfMemoryError: Metaspace 也就是元数据溢出。
5. 对象的创建与内存分配
5.1 创建对象
当 JVM 收到一个 new 指令时,会检查指令中的参数在常量池是否有这个符号的引用,还会检查该类是否已经被加载过了,如果没有的话则要进行一次类加载。
接着就是分配内存了,通常有两种方式:
- 指针碰撞
- 空闲列表
使用指针碰撞的前提是堆内存是完全工整的,用过的内存和没用的内存各在一边每次分配的时候只需要将指针向空闲内存一方移动一段和内存大小相等区域即可。
当堆中已经使用的内存和未使用的内存互相交错时,指针碰撞的方式就行不通了,这时就需要采用空闲列表的方式。虚拟机会维护一个空闲的列表,用于记录哪些内存是可以进行分配的,分配时直接从可用内存中直接分配即可。
堆中的内存是否工整是有垃圾收集器来决定的,如果带有压缩功能的垃圾收集器就是采用指针碰撞的方式来进行内存分配的。
分配内存时也会出现并发问题:
这样可以在创建对象的时候使用 CAS 这样的乐观锁来保证。
也可以将内存分配安排在每个线程独有的空间进行,每个线程首先在堆内存中分配一小块内存,称为本地分配缓存(TLAB : Thread Local Allocation Buffer)。
分配内存时,只需要在自己的分配缓存中分配即可,由于这个内存区域是线程私有的,所以不会出现并发问题。
可以使用 -XX:+/-UseTLAB 参数来设定 JVM 是否开启 TLAB 。
内存分配之后需要对该对象进行设置,如对象头。对象头的一些应用可以查看 Synchronize 关键字原理。
对象访问
一个对象被创建之后自然是为了使用,在 Java 中是通过栈来引用堆内存中的对象来进行操作的。
对于我们常用的 HotSpot 虚拟机来说,这样引用关系是通过直接指针来关联的。
如图:
这样的好处就是:在 Java 里进行频繁的对象访问可以提升访问速度(相对于使用句柄池来说)。
5.2 内存分配
####Eden 区分配
简单的来说对象都是在堆内存中分配的,往细一点看则是优先在 Eden 区分配。
这里就涉及到堆内存的划分了,为了方便垃圾回收,JVM 将堆内存分为新生代和老年代。
而新生代中又会划分为 Eden 区,from Survivor、to Survivor
区。
其中 Eden 和 Survivor 区的比例默认是8:1:1
,当然也支持参数调整 -XX:SurvivorRatio=8
。
当在 Eden 区分配内存不足时,则会发生 minorGC ,由于 Java 对象多数是朝生夕灭的特性,所以 minorGC 通常会比较频繁,效率也比较高。
当发生 minorGC 时,JVM 会根据复制算法
将存活的对象拷贝到另一个未使用的 Survivor 区,如果 Survivor 区内存不足时,则会使用分配担保策略将对象移动到老年代中。
谈到 minorGC 时,就不得不提到 fullGC(majorGC) ,这是指发生在老年代的 GC ,不论是效率还是速度都比 minorGC 慢的多,回收时还会发生 stop the world 使程序发生停顿,所以应当尽量避免发生 fullGC 。
老年代分配
也有一些情况会导致对象直接在老年代分配,比如当分配一个大对象时(大的数组,很长的字符串),由于 Eden 区没有足够大的连续空间来分配时,会导致提前触发一次 GC,所以尽量别频繁的创建大对象。
因此 JVM 会根据一个阈值来判断大于该阈值对象直接分配到老年代,这样可以避免在新生代频繁的发生 GC。
对于一些在新生代的老对象 JVM 也会根据某种机制移动到老年代中。
JVM 是根据记录对象年龄的方式来判断该对象是否应该移动到老年代,根据新生代的复制算法,当一个对象被移动到 Survivor 区之后 JVM 就给该对象的年龄记为1,每当熬过一次 minorGC 后对象的年龄就 +1 ,直到达到阈值(默认为15)就移动到老年代中。
可以使用-XX:MaxTenuringThreshold=15
来配置这个阈值。
###总结
虽说这些内容略显枯燥,但当应用发生不正常的 GC 时,可以方便更快的定位问题。
6. volatile 关键字
前言
不管是在面试还是实际开发中 volatile
都是一个应该掌握的技能。
首先来看看为什么会出现这个关键字。
6.1 内存可见性
由于 Java 内存模型(JMM)
规定,所有的变量都存放在主内存中,而每个线程都有着自己的工作内存(高速缓存)。
线程在工作时,需要将主内存中的数据拷贝到工作内存中。这样对数据的任何操作都是基于工作内存(效率提高),并且不能直接操作主内存以及其他线程工作内存中的数据,之后再将更新之后的数据刷新到主内存中。
这里所提到的主内存可以简单认为是堆内存
,而工作内存则可以认为是栈内存
。
如下图所示:
所以在并发运行时可能会出现线程 B 所读取到的数据是线程 A 更新之前的数据。
显然这肯定是会出问题的,因此 volatile 的作用出现了:
当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。
volatile 修饰之后并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中。
内存可见性的应用
当我们需要在两个线程间依据主内存通信时,通信的那个变量就必须的用 volatile 来修饰:
1 | public class Volatile implements Runnable{ |
主线程在修改了标志位使得线程 A 立即停止,如果没有用 volatile 修饰,就有可能出现延迟。
但这里有个误区,这样的使用方式容易给人的感觉是:
对 volatile 修饰的变量进行并发操作是线程安全的。
这里要重点强调,volatile 并不能保证线程安全性(不能保证其原子性)!
如下程序:
1 | public class VolatileInc implements Runnable{ |
当我们三个线程(t1,t2,main)同时对一个 int 进行累加时会发现最终的值都会小于 30000。
这是因为虽然 volatile 保证了内存可见性,每个线程拿到的值都是最新值,但 count ++ 这个操作并不是原子的,这里面涉及到获取值、自增、赋值的操作并不能同时完成。
所以想到达到线程安全可以使这三个线程串行执行(其实就是单线程,没有发挥多线程的优势)。
也可以使用 synchronize 或者是锁的方式来保证原子性。
还可以用 Atomic 包中 AtomicInteger 来替换 int,它利用了 CAS 算法来保证了原子性。
6.2 指令重排
内存可见性只是 volatile 的其中一个语义,它还可以防止 JVM 进行指令重排优化。
举一个伪代码:
1 | int a=10 ;//1 |
一段特别简单的代码,理想情况下它的执行顺序是:1>2>3。但有可能经过 JVM 优化之后的执行顺序变为了 2>1>3。
如果下一段的语句没有使用上一段语句的结果,JVM就认为两句语句是没有关联的就可能进行重排,JVM不会保证代码的执行顺序,但会保证最终的结果都是一样的
可以发现不管 JVM 怎么优化,前提都是保证单线程中最终结果不变的情况下进行的。
可能这里还看不出有什么问题,那看下一段伪代码:
1 | private static Map<String,String> value ; |
这里就能看出问题了,当 flag 没有被 volatile 修饰时,JVM 对 1 和 2 进行重排,导致 value 都还没有被初始化就有可能被线程 B 使用了。
所以加上 volatile 之后可以防止这样的重排优化,保证业务的正确性。
指令重排的的应用
一个经典的使用场景就是双重懒加载的单例模式了:
1 | public class Singleton { |
这里的 volatile 关键字主要是为了防止指令重排。
如果不用 ,singleton = new Singleton();,这段代码其实是分为三步:
- 分配内存空间。(1)
- 初始化对象。(2)
- 将 singleton 对象指向分配的内存地址。(3)
加上 volatile 是为了让以上的三步操作顺序执行,反之有可能第二步在第三步之前被执行就有可能某个线程拿到的单例对象是还没有初始化的,以致于报错。
总结
volatile 在 Java 并发中用的很多,比如像Atomic
包中的 value
、以及 AbstractQueuedLongSynchronizer
中的 state
都是被定义为 volatile 来用于保证内存可见性。
将这块理解透彻对我们编写并发程序时可以提供很大帮助。