# java-interview-教程 **Repository Path**: flyingsu_admin/javainterview_tutorials ## Basic Information - **Project Name**: java-interview-教程 - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2018-07-17 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README Java 运行时的内存划分 程序计数器 记录当前线程所执行的字节码行号,用于获取下一条执行的字节码。 当多线程运行时,每个线程切换后需要知道上一次所运行的状态、位置。由此也可以看出程序计数器是每个线程私有的。 虚拟机栈 虚拟机栈是有一个一个的栈帧组成,栈帧是在每一个方法调用时产生的。 每一个栈帧由局部变量区、操作数栈等组成。每创建一个栈帧压栈,当一个方法执行完毕之后则出栈。 如果出现方法递归调用出现死循环的话就会造成栈帧过多,最终会抛出 stackoverflow 异常。 这块内存区域也是线程私有的。 Java 堆 Java 堆是整个虚拟机所管理的最大内存区域,所有的对象创建都是在这个区域进行内存分配。 这块区域也是垃圾回收器重点管理的区域,由于大多数垃圾回收器都采用分代回收算法,所有堆内存也分为 新生代、老年代,可以方便垃圾的准确回收。 这块内存属于线程共享区域。 方法区 方法区主要用于存放已经被虚拟机加载的类信息,如常量,静态变量。 这块区域也被称为永久代。 运行时常量池 运行时常量池是方法区的一部分,其中存放了一些符号引用。当 new 一个对象时,会检查这个区域是否有这个符号的引用。 类加载机制 简介 Java类加载器是Java运行时环境(Java Runtime Environment)的一部分,负责动态加载Java类到Java虚拟机的内存空间中。类通常是按需加载,即第一次使用该类时才加载。 由于有了类加载器,Java运行时系统不需要知道文件与文件系统。每个Java类必须由某个类加载器装入到内存。 类装载器子系统涉及Java虚拟机的其他几个组成部分,以及几个来自java.lang库的类。比如,用户自定义的类装载器只是普通的Java对象,它的类必须派生自java.lang.ClassLoader。ClassLoader中定义的方法为程序提供了访问类装载器机制的接口。此外,对于每个被装载的类型,Java虚拟机都会为他创建一个java.lang.Class类的实例来代表该类型。和所有其他对象一样,用户自定义的类装载器以及Class类的实例都放在内存中的堆区,而装载的类型信息都位于方法区。 类装载器子系统除了要定位和导入二进制class文件外,还必须负责验证被导入类的正确性,为变量分配初始化内存,以及帮助解析符号引用。这些动作必须严格按一下顺序完成: 装载–查找并装载类型的二进制数据。 链接--执行验证、准备以及解析(可选) 验证 确保被导入类型的正确性 准备 为类变量分配内存,并将其初始化为默认值。 解析 把类型中的符号引用转换为直接引用。 初始化--把类变量初始化为正确的初始值。 分类 在Java虚拟机中存在多个类装载器,Java应用程序可以使用两种类装载器: 启动(bootstrap)类装载器:此装载器是Java虚拟机实现的一部分。由原生代码(如C语言)编写,不继承自java.lang.ClassLoader。负责加载核心Java库,存储在/jre/lib目录中。(如果Java虚拟机在已有操作系统中实现为C程序,那么启动类加载器就是此C程序的一部分) 启动类装载器通常使用某种默认的方式从本地磁盘中加载类,包括Java API。 用户自定义类装载器:(包含但不止,扩展类加载器以及系统类加载器) ,继承自Java中的java.lang.ClassLoader类,Java应用程序能在运行时安装用户自定义类装载器,这种累装载器使用自定义的方式来装载类。用户定义的类装载器能用Java编写,能够被编译为Class文件,能被虚拟机装载,还能像其他对象一样实例化。它们实际上只是运行中的Java程序可执行代码的一部分。一般JVM都会提供一些基本实现。应用程序的开发人员也可以根据需要编写自己的类加载器。JVM中最常使用的是系统类加载器(system),它用来启动Java应用程序的加载。 通过java.lang.ClassLoader.getSystemClassLoader() 可以获取到该类加载器对象。该类由sun.misc.Launcher$AppClassLoader实现。 全盘负责双亲委托机制 全盘负责是指当一个ClassLoader装载一个类的时,除非显式地使用另一个ClassLoader,该类所依赖及引用的类也由这个ClassLoader载入;“双亲委托机制”是指先委托父装载器寻找目标类,只有在找不到的情况下才从自己的类路径中查找并装载目标类。这一点是从安全角度考虑的,试想如果有人编写了一个恶意的基础类(如java.lang.String)并装载到JVM中将会引起多么可怕的后果。但是由于有了“全盘负责委托机制”,java.lang.String永远是由根装载器来装载的,这样就避免了上述事件的发生。 类加载器需要完成的最终功能是定义一个Java类,即把Java字节代码转换成JVM中的java.lang.Class类的对象。但是类加载的过程并不是这么简单。Java类加载器有两个比较重要的特征: 层次组织结构指的是每个类加载器都有一个父类加载器,通过getParent()方法可以获取到。类加载器通过这种父亲-后代的方式组织在一起,形成树状层次结构。 代理模式则指的是一个类加载器既可以自己完成Java类的定义工作,也可以代理给其它的类加载器来完成。由于代理模式的存在,启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能并不是一个。前者称为初始类加载器,而后者称为定义类加载器。 两者的关联在于:在每个类被装载的时候,Java虚拟机都会监视这个类,看它到底是被启动类装载器还是被用户自定义类装载器装载。当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。 注意:JVM加载类A,并使用A的ClassLoader去加载B,但B的类加载器并不一定和A的类加载器一致,这是因为有双亲委托机制的存在。 一般的类加载器在尝试自己去加载某个Java类之前,会 首先代理给其父类加载器。当父类加载器找不到的时候,才会尝试自己加载。这个逻辑是封装在java.lang.ClassLoader类的loadClass()方法中的。一般来说,父类优先的策略就足够好了。在某些情况下,可能需要采取相反的策略,即先尝试自己加载,找不到的时候再代理给父类加载器。这种做法在Java的Web容器中比较常见,也是Servlet规范推荐的做法。 比如,Apache Tomcat为每个Web应用都提供一个独立的类加载器,使用的就是自己优先加载的策略。IBM WebSphere Application Server则允许Web应用选择类加载器使用的策略。 假设 类加载器B2被要求装载类MyClass,在parent delegation模型下,类加载器B2首先请求类加载器B代为装载,类加载器B再请求系统类装载器去装载MyClass,系统类装载器也会继续请求它的Parent扩展类加载器去装载MyClass,以此类推直到引导类装载器。若引导类装载器能成功装载,则将MyClass所对应的Class对象的reference逐层返回到类加载器B2,若引导类装载器不能成功装载,下层的扩展类装载器将尝试装载,并以此类推直到类装载器B2如果也不能成功装载,则装载失败。 需要指出的是,Class Loader是对象,它的父子关系和类的父子关系没有任何关系。一对父子loader可能实例化自同一个 Class,也可能不是,甚至父loader实例化自子类,子loader实例化自父类。 运行时包 类加载器的一个重要用途是 在JVM中为相同名称的Java类创建隔离空间。在JVM中,判断两个类是否相同,不仅是根据该类的二进制名称,还需要根据两个类的定义类加载器。 只有两者完全一样,才认为两个类的是相同的。 在允许两个类型之间对包内可见的成员进行访问前,虚拟机不但要确定这个两个类型属于同一个包,还必须确认它们属于同一个运行时包-它们必须有同一个类装载器装载的。 这样,java.lang.Virus和来自核心的java.lang的类不属于同一个运行时包,java.lang.Virus就不能访问JAVA API的java.lang包中的包内可见的成员。 JVM垃圾回收 Java堆中存放着大量的Java对象实例,在垃圾收集器回收内存前,第一件事情就是确定哪些对象是“活着的”,哪些是可以回收的。 引用计数算法 引用计数算法是判断对象是否存活的基本算法:给每个对象添加一个引用计数器,没当一个地方引用它的时候,计数器值加1;当引用失效后,计数器值减1。但是这种方法有一个致命的缺陷,当两个对象相互引用时会导致这两个都无法被回收。 根搜索算法 在主流的商用语言中(Java、C#...)都是使用根搜索算法来判断对象是否存活。对于程序来说,根对象总是可以访问的。从这些根对象开始,任何可以被触及的对象都被认为是"活着的"的对象。无法触及的对象被认为是垃圾,需要被回收。 Java虚拟机的根对象集合根据实现不同而不同,但是总会包含以下几个方面: 虚拟机栈(栈帧中的本地变量表)中引用的对象。 方法区中的类静态属性引用的变量。 方法区中的常量引用的变量。 本地方法JNI的引用对象。 区分活动对象和垃圾的两个基本方法是引用计数和根搜索。 引用计数是通过为堆中每个对象保存一个计数来区分活动对象和垃圾。根搜索算法实际上是追踪从根结点开始的引用图。 引用对象 引用对象封装了指向其他对象的连接:被指向的对象称为引用目标。Reference有三个直接子类SoftReference、WeakReference、PhantomReference分别代表:软引用、弱引用、虚引用。强引用在Java中是普遍存在的,类似Object o = new Object();这类引用就是强引用,强引用和以上引用的区别在于:强引用禁止引用目标被垃圾收集器收集,而其他引用不禁止。 当使用软引用、弱引用、虚引用时,并且对可触及性状态的改变有兴趣,可以把引用对象和引用队列关联起来。 对象有六种可触及状态变化: 强可触及:对象可以从根节点不通过任何引用对象搜索到。垃圾收集器不会回收这个对象的内存空间。 软可触及:对象可以从根节点通过一个或多个(未被清除的)软引用对象触及,垃圾收集器在要发生内存溢出前将这些对象列入回收范围中进行回收,如果该软引用对象和引用队列相关联,它会把该软引用对象加入队列。 SoftReference可以用来创建内存中缓存,JVM的实现需要在抛出OutOfMemoryError之前清除软引用,但在其他的情况下可以选择清理的时间或者是否清除它们。 弱可触及:对象可以从根节点开始通过一个或多个(未被清除的)弱引用对象触及,垃圾收集器在一次GC的时候会回收所有的弱引用对象,如果该弱引用对象和引用队列相关联,它会把该弱引用对象加入队列。 可复活的:对象既不是强可触及、软可触及、也不是弱可触及,但仍然可能通过执行某些终结方法复活到这几个状态之一。 Java类可以通过重写finalize方法复活准备回收的对象,但finalize方法只是在对象第一次回收时会调用。 虚可触及:垃圾收集器不会清除一个虚引用,所有的虚引用都必须由程序明确的清除。 同时也不能通过虚引用来取得一个对象的实例。 不可触及:不可触及对象已经准备好回收了。 若一个对象的引用类型有多个,那到底如何判断它的可达性呢?其实规则如下: 单条引用链的可达性以最弱的一个引用类型来决定; 多条引用链的可达性以最强的一个引用类型来决定; Java 堆内存溢出 在 Java 堆中只要不断的创建对象,并且 GC-Roots 到对象之间存在引用链,这样 JVM 就不会回收对象。 只要将-Xms(最小堆),-Xmx(最大堆) 设置为一样禁止自动扩展堆内存。 当使用一个 while(true) 循环来不断创建对象就会发生 OutOfMemory,还可以使用 -XX:+HeapDumpOutofMemoryErorr 当发生 OOM 时会自动 dump 堆栈到文件中。 伪代码: public static void main(String[] args) { List list = new ArrayList<>(10) ; while (true){ list.add("1") ; } } 当出现 OOM 时可以通过工具来分析 GC-Roots 引用链 ,查看对象和 GC-Roots 是如何进行关联的,是否存在对象的生命周期过长,或者是这些对象确实改存在的,那就要考虑将堆内存调大了。 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.Arrays.copyOf(Arrays.java:3181) at java.util.ArrayList.grow(ArrayList.java:261) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227) at java.util.ArrayList.add(ArrayList.java:458) at com.crossoverjie.oom.HeapOOM.main(HeapOOM.java:18) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147) Process finished with exit code 1 java.lang.OutOfMemoryError: Java heap space表示堆内存溢出。 垃圾回收算法 标记--清除算法 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记的方法使用根搜索算法。主要有两个缺点: 效率问题,标记和清除的效率都不高。 空间问题,标记清除后会产生大量不连续的内存碎片。 复制回收算法 将可用内存分为大小相等的两份,在同一时刻只使用其中的一份。当这一份内存使用完了,就将还存活的对象复制到另一份上,然后将这一份上的内存清空。复制算法能有效避免内存碎片,但是算法需要将内存一分为二,导致内存使用率大大降低。 标记--整理算法 复制算法在对象存活率较高的情况下会复制很多的对象,效率会很低。标记--整理算法就解决了这样的问题,标记过程和标记--清除算法一样,但后续是将所有存活的对象都移动到内存的一端,然后清理掉端外界的对象。 分代回收算法 在JVM中不同的对象拥有不同的生命周期,因此对于不同生命周期的对象也可以采用不同的垃圾回收方法,以提高效率,这就是分代回收算法的核心思想。 在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费的时间相对会长。同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。 JVM中的共划分为三个代:新生代(Young Generation)、老年代(Old Generation)和持久代(Permanent Generation)。其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。 新生代:所有新生成的对象首先都是放在新生代的,新生代采用复制回收算法。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。新生代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。 在HotSpot虚拟机内部默认Eden和Survivor的大小比例是8:1, 也就是每次新生代中可用内存为整个新生代的90%,这大大提高了复制回收算法的效率。 老年代:在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中,老年代采用标记整理回收算法。因此,可以认为老年代中存放的都是一些生命周期较长的对象。 持久代:用于存放静态文件,如final常量,static常量,常量池等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。 垃圾回收触发条件 由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。对于一个拥有终结方法的对象,在垃圾收集器释放对象前必须执行终结方法。但是当垃圾收集器第二次收集这个对象时便不会再次调用终结方法。 Scavenge GC 一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区,然后整理Survivor的两个区。这种方式的GC是对新生代的Eden区进行,不会影响到老年代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。 Full GC 对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC: 老年代(Tenured)被写满 持久代(Perm)被写满 System.gc()被显示调用 垃圾收集器 垃圾收集器是内存回收的具体实现,下图展示了7种用于不同分代的收集器,两个收集器之间有连线表示可以搭配使用。下面的这些收集器没有“最好的”这一说,每种收集器都有最适合的使用场景。 Serial收集器 Serial收集器是最基本的收集器,这是一个单线程收集器,它“单线程”的意义不仅仅是说明它只用一个线程去完成垃圾收集工作,更重要的是在它进行垃圾收集工作时,必须暂停其他工作线程,直到它收集完成。Sun将这件事称之为”Stop the world“。 没有一个收集器能完全不停顿,只是停顿的时间长短。 虽然Serial收集器的缺点很明显,但是它仍然是JVM在Client模式下的默认新生代收集器。它有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比较),Serial收集器由于没有线程交互的开销,专心只做垃圾收集自然也获得最高的效率。在用户桌面场景下,分配给JVM的内存不会太多,停顿时间完全可以在几十到一百多毫秒之间,只要收集不频繁,这是完全可以接受的。 Parallel Scavenge收集器 Parallel Scavenge收集器是一个新生代垃圾收集器,其使用的算法是复制算法,也是并行的多线程收集器。 Parallel Scavenge 收集器更关注可控制的吞吐量,吞吐量等于运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。 停顿时间越短就越适合需要与用户交互的程序;而高吞吐量则可以最高效的利用CPU的时间,尽快的完成计算任务,主要适用于后台运算。 Serial Old收集器 Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。其运行过程与Serial收集器一样。 Parallel Old收集器 Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法进行垃圾回收。其通常与Parallel Scavenge收集器配合使用,“吞吐量优先”收集器是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。 CMS 收集器 CMS(Concurrent Mark Sweep)收集器是一种以获取最短停顿时间为目标的收集器,CMS收集器采用标记--清除算法,运行在老年代。主要包含以下几个步骤: 初始标记 并发标记 重新标记 并发清除 其中初始标记和重新标记仍然需要“Stop the world”。初始标记仅仅标记GC Root能直接关联的对象,并发标记就是进行GC Root Tracing过程,而重新标记则是为了修正并发标记期间,因用户程序继续运行而导致标记变动的那部分对象的标记记录。 由于整个过程中最耗时的并发标记和并发清除,收集线程和用户线程一起工作,所以总体上来说,CMS收集器回收过程是与用户线程并发执行的。虽然CMS优点是并发收集、低停顿,很大程度上已经是一个不错的垃圾收集器,但是还是有三个显著的缺点: CMS收集器对CPU资源很敏感。在并发阶段,虽然它不会导致用户线程停顿,但是会因为占用一部分线程(CPU资源)而导致应用程序变慢。 CMS收集器不能处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们,只好在下一次GC的时候处理,这部分未处理的垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段程序还需要运行,即还需要预留足够的内存空间供用户使用,因此CMS收集器不能像其他收集器那样等到老年代几乎填满才进行收集,需要预留一部分空间提供并发收集时程序运作使用。要是CMS预留的内存空间不能满足程序的要求,这是JVM就会启动预备方案:临时启动Serial Old收集器来收集老年代,这样停顿的时间就会很长。 由于CMS使用标记--清除算法,所以在收集之后会产生大量内存碎片。当内存碎片过多时,将会给分配大对象带来困难,这是就会进行Full GC。 G1收集器 G1收集器与CMS相比有很大的改进: G1收集器采用标记--整理算法实现。 可以非常精确地控制停顿。 G1收集器可以实现在基本不牺牲吞吐量的情况下完成低停顿的内存回收,这是由于它极力的避免全区域的回收,G1收集器将Java堆(包括新生代和老年代)划分为多个区域(Region),并在后台维护一个优先列表,每次根据允许的时间,优先回收垃圾最多的区域 。 JVM优化 堆大小设置 年轻代的设置很关键 JVM中最大堆大小有三方面限制:相关操作系统的数据模型(32-bt还是64-bit)限制;系统的可用虚拟内存限制;系统的可用物理内存限制。32位系统下,一般限制在1.5G~2G;64为操作系统对内存无限制。在Windows Server 2003 系统,3.5G物理内存,JDK5.0下测试,最大可设置为1478m。 典型设置: java -Xmx3550m -Xms3550m -Xmn2g –Xss128k -Xmx3550m:设置JVM最大可用内存为3550M。 -Xms3550m:设置JVM促使内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。 -Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。 -Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。 java -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0 -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5 -XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6 -XX:MaxPermSize=16m:设置持久代大小为16m。 -XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。