image.png
    一、JVM运行时数据区和直接内存
    image.png
    1. 堆(Java堆)
    堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建,该内存区域存放了对象实例(包括基本类型的变量及其值)及数组(所有new的对象)。但是并不是所有的对象都在堆上,由于栈上分配和标量替换,导致有些对象不在堆上。
    其大小通过-Xms(最小值)和-Xmx(最大值)参数设置,-Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G,默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation 来指定这个比列;当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation来指定这个比例,对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。
    由于现在收集器都是采用分代收集算法,堆被划分为新生代和老年代。新生代主要存储新创建的对象和尚未进入老年代的对象。老年代存储经过多次新生代GC(Minor GC)仍然存活的对象。
    堆中没有足够的内存完成实例分配,并且堆也无法扩展时,将会出现OOM异常。(内存泄漏 / 内存溢出)。满足下面两个条件就会抛出OOM。
    (1)JVM 98% 的时间都花费在内存回收。
    (2)每次回收的内存小于2%。
    同一对象在执行期间若已经存储在集合中,则不能修改影响hashCode值的相关信息,否则会导致内存泄露问题。
    image.png
    1.1 为什么要分代
    堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率,这简直太可怕了。
    有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
    1.2 新生代
    程序新创建的对象都是从新生代分配内存,新生代由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
    GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。
    GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
    可通过-Xmn参数来指定新生代的大小,也可以通过-XX:SurvivorRation来调整Eden Space及Survivor Space的大小。
    1.3 老年代
    用于存放经过多次新生代GC任然存活的对象,老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。老年代所占的内存大小为-Xmx对应的值减去-Xmn对应的值。
    主要存储的有:如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:
    ①.大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。
    ②.大的数组对象,且数组中无引用外部对象。
    1.4 Java8 内存分代的改进
    在 JDK 1.8 中, HotSpot 已经没有 “PermGen space”这个区域了,取而代之是一个叫做 Metaspace(元空间) 的东西。
    实际上在JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
    元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
    -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
    -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
    -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。
    -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
    取消永久代的原因:
    (1)字符串存在永久代中,容易出现性能问题和内存溢出。
    (2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
    (3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
    2.方法区(永久代(针对于HotSpot虚拟机),非堆)
    它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。
    默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。
    运行时常量池:是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
    方法区空间不够的时候出现OOM。(主流框架中,通过字节码技术动态生成大量的Class)
    3.程序计数器
    是最小的一块内存区域,它的作用是当前线程所执行的字节码的行号指示器,在虚拟机的模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖计数器完成。
    唯一一个没有OOM的区域。
    4.虚拟机栈
    描述的是java 方法执行的内存模型:每个方法被执行的时候 都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。生命周期与线程相同,是线程私有的。
    JDK 1.5 以后每个线程堆栈默认大小为1M,以前每个线程栈大小为256K。可以通过 -Xss 参数来设置每个线程的堆栈大小。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大;如果该值设置过大,就会影响到创建线程的数量,当遇到多线程的应用时可能出现内存溢出的错误。
    局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。
    线程请求的栈深度大于虚拟机允许的深度将抛出StackOverflowError异常,或者创建线程数量较多时(每个线程都会创建私有的栈内存)会出现栈内存溢出StackOverflow。如果虚拟机可以动态扩展,扩展的时候无法申请足够的内存就会抛出OOM异常。(无限递归)
    5.本地方法栈
    与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务。

    直接内存:
    直接内存并不是虚拟机内存的一部分,也不是Java虚拟机规范中定义的内存区域。jdk1.4中新加入的NIO,引入了通道与缓冲区的IO方式,它可以调用Native方法直接分配堆外内存,这个堆外内存就是本机内存,不会影响到堆内存的大小。

    上述区域中,除了程序计数器之外都有可能出现OOM,那么出现OOM如何解决
    (1)通过参数 -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现OOM异常的时候Dump出内存映像以便于分析。
    (2)通过内存映像分析工具(Eclipse Memory Analyzer)对映像文件进行分析,首先确认内存中的对象是否有必要存活。(到底是出现了内存泄漏还是内存溢出)
    哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系,还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的瓶颈。
    (3)如果是内存泄漏,可进一步通过工具查看GC root引用链,找到引用信息,可以准确的定位出内存泄漏的代码位置。(HashMap中的元素的某些属性改变了,影响了hashcode的值会发生内存泄漏)
    (4)如果不存在内存泄漏,就应当检查虚拟机的参数是否可以调大;修改代码逻辑,把某些对象生命周期过长,持有状态时间过长等情况的代码修改。

    内存泄漏的场景:
    (1)使用静态的集合类
    静态的集合类的生命周期和应用程序的生命周期一样长,所以在程序结束前容器中的对象不能被释放,会造成内存泄露。
    解决办法是最好不使用静态的集合类,如果使用的话,在不需要容器时要将其赋值为null。
    (2)单例模式可能会造成内存泄露
    单例模式只允许应用程序存在一个实例对象,并且这个实例对象的生命周期和应用程序的生命周期一样长,如果单例对象中拥有另一个对象的引用的话,这个被引用的对象就不能被及时回收。
    解决办法是单例对象中持有的其他对象使用弱引用,弱引用对象在GC线程工作时,其占用的内存会被回收掉。
    (3)数据库、网络、输入输出流,这些资源没有显示的关闭
    垃圾回收只负责内存回收,如果对象正在使用资源的话,Java虚拟机不能判断这些对象是不是正在进行操作,比如输入输出,也就不能回收这些对象占用的内存,所以在资源使用完后要调用close()方法关闭。
    二、类的加载和实例化
    1.类加载(整体的加载过程)的五个过程
    image.png

    • 加载:类加载过程的一个阶段:(1)通过一个类的全限定名查找此类字节码文件;(2)把字节码代表的静态存储结构转化为运行时动态数据结构;(3)并创建一个代表这个类的Class对象(Class对象比较特殊,存放在方法区),作为方法区这个类的各种数据结访问的入口。
    • 验证:目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。(主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。)
    • 准备:为类变量(即static修饰的字段变量)分配内存(方法区)并且设置该类变量的初始值(如static int i=5;这里只将i初始化为0,至于5的值将在初始化时赋值),这里不包含用final修饰的static,因为final在编译的时候就会分配了(也分配在方法区),注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
    • 解析:主要将常量池中(在方法区)的符号引用替换为直接引用的过程。符号引用(Java虚拟机规范中定义的引用)就是一组符号来描述目标,可以是任何字面量,而直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。(有类或接口的解析,字段解析,类方法解析,接口方法解析)
    • 初始化:类加载最后阶段,到了初始化阶段,才真正开始执行类中定义的java程序代码。若该类具有超类,则对其进行初始化(一个类在初始化的时候要求其父类全部初始化了,但是一个接口初始化的时候不要求其父接口全部都初始化了);执行静态初始化器和静态初始化成员变量(如前面只初始化了默认值的 static 变量将会在这个阶段赋值,成员变量也将被初始化)。

    虚拟机规定了只有四种情况才会触发类的初始化操作,称为一个类的主动引用:
    (1)启动虚拟机时,用户需要指定一个要执行的主类,虚拟机会先执行主类。
    (2)遇到 new(新建对象)、getstatic(获取静态字段)、putstatic(设置静态字段)、invokstatic(调用静态方法)四条字节码指令时,如果该类还没有初始化,需要先触发初始化。
    (3)当初始化一个类时,发现其父类还没有初始化,则需要先初始化其父类。
    (4)使用Java.lang.refect 包的方法对类进行发射调用时。
    2.类加载器
    2.1 启动(Bootstrap)类加载器
    启动类加载器主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 /lib路径下的核心类库或-Xboot/classpath参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的。
    2.2 扩展(Extension)类加载器
    负责加载ext 目录(jre\lib)内的类。
    2.3 系统(System)类加载器
    也称应用程序加载器,它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

    在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理。
    3.双亲委派模型
    双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,请注意双亲委派模式中的父子关系并非通常所说的类继承关系,而是采用组合关系来复用父类加载器的相关代码。
    image.png
    3.1 原理:
    如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。
    3.2 优势
    (1)采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。
    (2)其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
    4.类的实例化顺序
    (1) 类中的static代码在类加载时就执行,且只执行这一次。(先静态)
    (2)如果一个类A继承了类B,在类A实例化时要先加载类B,在执行类A的构造函数时要先执行父类B的构造函数,所以此时要实例化类B。(先父后子)
    (3)一个类实例化之前要先实例化类的成员变量(非静态),再执行该类的构造函数。(先成员后构造)
    三、垃圾回收机制
    1.垃圾回收器负责:

    • 分配内存
    • 保证所有正在被引用的对象还存在于内存中
    • 回收执行代码已经不再引用的对象所占的内存

    2. GC回收策略
    JVM回收的对象,是那些已经不再被使用的对象。而判断是否不再被使用的原则,可以从两个方面来描述。第一是不可到达,第二是引用计数为0。这两点,其实说的是一回事,从不同的方面来描述罢了。
    2.1 不可达
    从GC Root出发,对象之间的引用链没有指向的对象,我们称之为不可到达。
    2.2 引用计数为0
    引用计数为0其实说的就是从GC Root开始的对象引用链到达该对象的引用计数为0,即没有可到达的引用路径指向它了。
    在Java语言中,可以作为GC Roots的对象包括下面几种:
    (1)虚拟机栈中引用的对象。
    (2)方法区中静态属性引用的对象。
    (3)方法区中常量引用的对象。
    (4)本地方法栈中本地方法引用的对象。
    2.3 四种类型的引用
    强引用:使用最普遍的引用:Object o=new Object(); 特点:不会被GC。将对象的引用显示地置为null:o=null; (帮助垃圾收集器回收此对象)
    软引用:用来描述一些还有用但是并非必须的对象,在Java中用java.lang.ref.SoftReference类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。
    弱引用:与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象(前提是此对象不再被其他外部对象引用),不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference 来记住此对象。
    ThreadLocalMap 使用弱引用的目的:当 threadLocal 实例可以被GC回收时,系统可以检测到该 threadLocal 对应的Entry是否已经过期(根据reference.get() == null来判断,如果为true则表示过期,程序内部称为stale slots)来自动做一些清除工作,否则如果不清除的话容易产生内存无法释放的问题——value对应的对象即使不再使用,但由于被threadLocalMap所引用导致无法被GC回收。
    虚引用(幻影引用):一个对象是否有虚引用的存在完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的真实引用。唯一的用处:能在对象被GC时收到系统通知,JAVA中用PhantomReference来实现虚引用。
    2.4 Minor GC流程
    (1)绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;
    (2)最初一次,当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);
    (3)下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到Survivor1中,然后清空Eden区;
    (4)将Survivor0中消亡的对象清理掉,将其中可以晋级的对象晋级到Old区,将存活的对象也复制到Survivor1区,然后清空Survivor0区;
    (5)当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。
    2.5 如何手动触发GC
    (1)System.gc()方法。
    (2)Runtime.getRuntime().gc()。
    上面的指令只是告诉JVM尽快GC一次,但不会立即执行GC。
    3.次收集(Minor GC)和全收集(Full GC)
    当新生代堆空间(Eden区)满了的时候,会触发次收集。当老年代堆空间满了的时候,会触发一个覆盖全范围的对象堆的全收集。
    次收集

    • 当年轻代堆空间紧张时会被触发
    • 相对于全收集而言,收集间隔较短

    全收集

    • 通常会伴随一次Minor GC,全收集一般根据堆大小的不同,需要的时间不尽相同,但一般会比较长。

    (1) 使用System.gc()方法来显式的启动老年代收集(会触发full gc)。
    (2) 当老年代或者永久代(Hptspot虚拟机)堆空间满了,会触发全收集操作。
    (3) 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间,直接触发Full GC。
    (4) CMS GC时出现promotion failed和concurrent mode failure。
    promotion failed是在进行Minor GC时,survivor space放不下,对象只能放入老年代,而此时老年代也放不下造成的。(分配担保失败)
    concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。(年龄够了,需要进入老年代)
    4.垃圾收集算法
    1) 标记-清除算法 Mark-Sweep(标记、清除的效率不高;内存碎片)
    标记阶段和清除阶段。通过可达性分析,标记出存活的对象,没有标记的就是未引用的垃圾对象;在清除阶段,清除所有未被标记的对象。
    缺点:
    ①标记清除算法带来的一个问题是会存在大量的空间碎片,因为回收后的空间是不连续的,这样给大对象分配内存的时候可能会提前触发full gc。
    ②效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲,尤其对于交互式的应用程序来说简直是无法接受。
    2) 复制算法 Copying(简单、高效;内存缩小为了原来的一半)
    复制(Copying)算法是为了解决”标记 - 清除“算法的效率问题而生,它将内存划分为容量大小相等的两块,每次只使用其中一块,当这一块内存使用完了,就将还存活的对象复制到另外一块当中,然后把原来那一块内存清空。
    优点:
    这样每次都是对整个半区进行内存回收,内存分配时也不用考虑空间碎片的情况,只要移动堆顶指针按顺序分配即可,实现简单,运行高效。
    缺点:
    ①只是这种算法把内存缩小为原来的一半,代价高昂。
    ②复制算法在对象存活率比较高的时候,就要进行非常多的复制操作,使得效率变低。
    3) 标记-整理算法 Mark-Compact(存活的对象向一端移动)
    4) 分代收集器 Generational(新生代使用方法2,老年代使用方法1或者3)
    在新生代中,每次垃圾收集都有大批量的对象死去,只有少量存活,那就使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中对象存活率较高,也没有额外空间进行分配担保,所以就必须使用标记 - 清除或者标记 - 整理算法来进行回收。
    5.垃圾收集器
    垃圾收集器是GC的具体实现,Java虚拟机规范中对于垃圾收集器没有任何规定,所以不同厂商实现的垃圾 收集器各不相同,HotSpot 1.7/1.8版使用的垃圾收集器如下图所示:
    image.png
    5.1 Serial收集器
    image.png
    Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;JDK1.3.1前是HotSpot新生代收集的唯一选择;
    特点:针对新生代;采用复制算法;单线程收集;进行垃圾收集时,必须暂停所有工作线程,直到完成;
    5.2 ParNew收集器
    image.png
    ParNew垃圾收集器是Serial收集器的多线程版本
    5.3 Parallel Scavenge收集器(吞吐量)
    image.png
    Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)
    特点:(A)、有一些特点与ParNew收集器相似
    新生代收集器;采用复制算法;多线程收集;
    (B)、主要特点是:它的关注点与其他收集器不同
    CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
    而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)
    5.4 Parallel Old收集器
    Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;JDK1.6中才开始提供;
    特点: 针对老年代;采用”标记-整理”算法;多线程收集;
    5.5 Serial Old收集器
    Serial Old是 Serial收集器的老年代版本
    特点:针对老年代;采用”标记-整理”算法(还有压缩,Mark-Sweep-Compact);单线程收集;
    5.6 CMS收集器(低延迟)
    image.png
    并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为低延迟(low-latency)垃圾收集器,是 HotSpot 在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器; 第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
    总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间。
    5.6.1 特点:
    针对老年代;
    基于”标记-清除”算法(不进行压缩操作,产生内存碎片);
    以获取最短回收停顿时间为目标;
    并发收集、低停顿;
    需要更多的内存(看后面的缺点);
    5.6.2 运作过程:
    (A)、初始标记(CMS initial mark)
    仅标记一下GC Roots能直接关联到的对象; 速度很快;但需要”Stop The World”;
    (B)、并发标记(CMS concurrent mark)
    进行GC Roots Tracing的过程;刚才产生的集合中标记出存活对象;
    应用程序也在运行;并不能保证可以标记出所有的存活对象;
    (C)、重新标记(CMS remark)
    为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;
    采用多线程并行执行来提升效率;
    (D)、并发清除(CMS concurrent sweep)
    回收所有的垃圾对象; 整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行。
    5.6.3 CMS收集器3个明显的缺点
    (1)对CPU资源非常敏感
    并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
    (2)无法处理浮动垃圾
    在并发清除时,用户线程新产生的垃圾,称为浮动垃圾; 这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集。
    (3)产生大量内存碎片
    由于CMS基于”标记-清除”算法,清除后不进行压缩操作,产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。
    5.7 G1收集器
    image.png
    G1(Garbage-First)是JDK7才推出商用的收集器;
    5.7.1 特点:
    (A)、并行与并发
    能充分利用多CPU、多核环境下的硬件优势;
    可以并行来缩短”Stop The World”停顿时间;
    也可以并发让垃圾收集与用户程序同时进行;
    (B)、分代收集(收集范围包括新生代和老年代)
    能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
    能够采用不同方式处理不同时期的对象;
    虽然保留分代概念,但Java堆的内存布局有很大差别;
    将整个堆划分为多个大小相等的独立区域(Region);
    新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
    (C)、结合多种垃圾收集算法(空间整合,不产生碎片)
    从整体看,是基于标记-整理算法;
    从局部(两个Region间)看,是基于复制算法;
    都不会产生内存碎片,有利于长时间运行;
    (D)、可预测的停顿(低停顿的同时实现高吞吐量)
    可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
    G1除了追求低停顿处,还能建立可预测的停顿时间模型:
    可以有计划地避免在Java堆的进行全区域的垃圾收集,G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表, 每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来), 这就保证了在有限的时间内可以获取尽可能高的收集效率。
    5.7.2 运作过程(与CMS较为类似)
    (A)、初始标记(Initial Marking)
    仅标记一下GC Roots能直接关联到的对象;
    且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
    需要”Stop The World”,但速度很快;
    (B)、并发标记(Concurrent Marking)
    进行GC Roots Tracing的过程;
    刚才产生的集合中标记出存活对象;
    耗时较长,但应用程序也在运行;
    并不能保证可以标记出所有的存活对象;
    (C)、最终标记(Final Marking)
    为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
    上一阶段对象的变化记录在线程的Remembered Set Log,这里把Remembered Set Log合并到Remembered Set中;
    需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;
    采用多线程并行执行来提升效率。
    (D)、筛选回收(Live Data Counting and Evacuation)
    首先排序各个Region的回收价值和成本;
    然后根据用户期望的GC停顿时间来制定回收计划;
    最后按计划回收一些价值高的Region中垃圾对象;
    回收时采用”复制”算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
    可以并发进行,降低停顿时间,并增加吞吐量;

    四、编译优化和运行期优化
    4.1 编译器类型
    JVM的编译器可以分为三个编译器:
    (1)前端编译器:把.java转变为.class的过程。如Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
    (2)JIT编译器:把字节码转变为机器码的过程,如HotSpot VM的C1、C2编译器。
    (3)AOT编译器:静态提前编译器,直接将.java文件编译本地机器代码的过程。
    *4.2 编译过程

    image.png

    4.3 编译优化(早期Javac阶段)
    Java语法糖
    泛型和类型擦除
    自动装箱、拆箱与循环遍历
    条件编译
    4.4 运行期优化
    Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。 为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称JIT编译器)。
    当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。
    常用优化技术:
    (1)方法内联(Method Inlining)一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础。
    (2)冗余访问消除(Redundant Loads Elimination)
    (3)复写传播(Copy Propagation)
    (4)无用代码消除(Dead Code Elimination)
    (5)公共子表达式消除
    (6)数组边界检查消除(Array Bounds Checking Elimination)
    (7)逃逸分析
    如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。

    五、Java内存模型(Java Memory Model,JMM)
    image.png
    5.1 JMM的定义和作用
    内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型(JMM)。
    JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,能够实现Java程序的“一次编写,到处运行”。
    Java内存模型的主要目标是定义程序中各个变量(共享数据)的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程时所说的变量不一样,包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。
    Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存拷贝的副本),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
    5.2 内存交互操作(先行发生原则)
    如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
    5.3 volatile变量规则
    (1)volatile的语义
    关键字volatile是JVM中最轻量的同步机制,volatile变量具有2种特性:

    • 保证变量的可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。
    • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段。

    (2)并不一定保证线程安全
    由于 volatile 只能保证变量的可见性和屏蔽指令重排序,只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。

    • 运算结果不存在数据依赖,或者只有单一的线程修改变量的值
    • 变量不需要与其他的状态变量共同参与不变约束

    因为需要在本地代码中插入许多内存屏蔽指令屏蔽特定条件下的重排序,volatile变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。
    (3)工作机制
    加入volatile关键字时在汇编代码中会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
    1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(禁止重排序)
    2)它会强制将对缓存的修改操作立即写入主存;(可见性)
    3)如果是写操作,它会导致其他CPU中对应的缓存行无效。(可见性)
    5.4 先行发生原则(happens-before)
    happens-before是JMM定义的2个操作之间的偏序关系:如果操作A先行发生于操作B,则A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作具有顺序性,此时不能够随意的重排序。否则,无法保证顺序性,就能进行指令的重排序。

    六、JVM 性能调优
    默认情况不做任何设置 JVM 会工作的很好,但对一些具体的应用必须仔细调优才能获得最佳性能,需要对内存、垃圾回收相关的一些参数进行设置,希望达到下列目标:

    • GC的时间足够的小
    • GC的次数足够的少
    • 发生Full GC的周期足够的长

    前两个目前是相悖的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。
    (1)针对 JVM 堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值。
    (2)新生代和老年代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率 NewRadio 来调整二者之间的大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。
    年轻代和年老代设置多大才算合理?这个问题毫无疑问是没有答案的,否则也就不会有调优。

    • 更大的新生代必然导致更小的老年代,大的新生代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC。
    • 小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率
    • 如何选择应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。
    • 在抉择时应该根据以下两点:(A)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 (B)通过观察应用一段时间,看其在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1,但应该给年老代至少预留1/3的增长空间。

      (3)在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC ,默认为Serial收集。
      (4)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

    七、题目汇总
    1、 Java代码整个从源码到运行过程?
    Java 代码编译和执行的整个过程包含了以下三个重要的机制:
    Java 源码编译机制
    类加载机制
    类执行机制
    (1)Java 源码编译由以下三个过程组成:
    分析和输入到符号表
    注解处理
    语义分析和生成 class 文件
    image.png
    (2)类加载
    (3)类执行机制
    JVM 是基于栈的体系结构来执行 class 字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。

    扩展:C语言编译的过程
    1、预处理(Preprocessing),
    将所有的#include头文件以及宏定义替换成其真正的内容,预处理之后得到的仍然是文本文件,但文件体积会大很多。
    2、编译(Compilation)
    将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。
    3、汇编(Assemble),
    将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件,是二进制格式。
    4、链接(Linking)
    将多个目标文以及所需的库文件(.so等)链接成最终的可执行文件(executable file)。
    image.png
    2、程序运行过程中——内存分配中的堆栈的区别
    (1)堆
    由程序员手动完成申请的,其实现方式与数据结构中的堆完全不同,此时的堆的实现方式有些类似于数据结构中的链表。
    (2)栈
    由编译器自动分配和释放的,主要存放参数和局部变量,作方式类似于数据结构中的栈。