java高级工程师面试题及答案解析干货汇总-JVM篇

JVM篇


Java代码通过编译器转换成Java字节码(class file),class file通过类加载器(class loader)加载到JVM内存中,把class file文件放进运行时数据中的方法区,在堆中创造java.lang.class对象封装类在方法区中的数据结构。class file需要调用本地方法库才能实现程序。

一、JDK,JRE以及JVM的关系

03.png
1.JVM可以帮助我们屏蔽底层的操作系统 ,一次编译,到处运行

2.JVM可以运行Class文件

image.png
3.JVM组成
在这里插入图片描述
JVM包含两个子系统和两个组件,
2个子系统:Class loader(类装载)、Execution engine(执行引擎);
2个组件:Runtime data area(运行时数据区)、Native Interface(本地接口)。

Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime
data area中的method area。
Execution engine(执行引擎):执行classes中的指令。
Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

二、我们的编译器到底干了什么事?

仅仅是将我们的 .java 文件转换成了 .class 文件,实际上就是文件格式的转换,对等信息转换。
image.png
首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;
再利用编译器(javac命令)将源代码编译成字节码文件,字节码文件的后缀名为.class;
运行字节码的工作是由解释器(java命令)来完成的。

其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。

三、类加载机制是什么?

image.png
所谓类加载机制

1、虚拟机把Class文件加载到内存
2、对数据进行校验,转换解析和初始化
3、形成可以虚拟机直接使用的Java类型,即java.lang.Class

3.1 装载(Load)

ClassFile —> 二进制字节流 —> 类加载器

查找和导入class文件

步骤:
(1)通过一个类的全限定名获取定义此类的二进制字节流
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
(3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

3.2 链接(Link)

3.2.1 验证(Verify)

保证被加载类的正确性

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证
    比如,A—>B,就是符号引用

3.2.2 准备(Prepare)

为类的静态变量分配内存,并将其初始化为默认值

private static int a = 1 ;
它在准备这个阶段 a = 0;

3.2.3 解析(Resolve)

把类中的符号引用转换为直接引用

  • 符号引用就是一组符号来描述目标,可以是任何字面量。比如,A—>B
  • 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。比如,A—>B(010X1)

解析阶段是虚拟机将常量池内的符号引用动态确定具体值的过程,把符号引用转换成直接引用的过程。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

3.3 初始化(Initialize)

执行到Clinit方法,为静态变量赋值,初始化静态代码块,初始化当前类的父类

四、JVM类加载机制的三种特性?类加载器又有哪些?

4.1 类加载器ClassLoader

在装载(Load)阶段,其中第(1)步通过类的全限定名获取其定义的二进制字节流,需要借助类装载器完成,顾名思义,就是用来装载Class文件的。

类加载器层级图解:
image.png
1)Bootstrap ClassLoader 负责加载 JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。

2)Extension ClassLoader 负责加载java平台中扩展功能的一些jar包,包括`$$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包。

3)App ClassLoader 负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。

4)Custom ClassLoader 通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。

4.2 类加载机制三种特性

4.2.1 全盘负责

当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

例如,系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所依赖的类及引用的类也载入,依此类推。“全盘负责”机制也可称为当前类加载器负责机制。显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。

以上步骤只是调用了ClassLoader.loadClass(name)方法,并没有真正定义类。真正加载class字节码文件生成Class对象由“双亲委派”机制完成。

4.2.2 双亲委派(1.8及以后,父类委托机制)

向上检查,向下委派

向上检查的过程就是通过类的全限定名,去逐渐向上查找是否已加载。通过APPClassloader,然后一直向上检查到BOOTClassloader,判断是否已经加载过,这里其实就是检查内存有没有同名类已经被加载,如果没有,向下委派加载。首先从BOOTClassloader向下加载,判断是否在对应的包中能够找到全限定名的类,找得到,则被加载,找不到,一直向下委派检查到APPClassloader,如果找到就加载。如果找不到,报ClassNotFoundException。

“双亲委派”机制加载Class的具体过程是:

  1. ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托给父类加载器。
  2. 父类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则委托给祖父类加载器。
  3. 依此类推,直到始祖类加载器(引用类加载器)。
  4. 始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的子类加载器。
  5. 始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。
  6. 依此类推,直到源ClassLoader。
  7. 源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。

image.png

4.2.3 缓存机制

缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用

而这里我们JDK8使用的是直接内存,所以我们会用到直接内存进行缓存。这也就是我们的类变量为什么只会被初始化一次的由来。

   protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First,在虚拟机内存中查找是否已经加载过此类...类缓存的主要问题所在!!!
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
						//先让上一层加载器进行加载
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
					//调用此类加载器所实现的findClass方法进行加载
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
				//resolveClass方法是当字节码加载到内存后进行链接操作,对文件格式和字节码验证,并为 static 字段分配空间并初始化,符号引用转为直接引用,访问控制,方法覆盖等
                resolveClass(c);
            }
            return c;
        }
    }

五、如何打破双亲委派?有哪几种方式?

“双亲委派”机制只是Java推荐的机制,并不是强制的机制。

第一种,集成ClassLoader抽象类,重写loadClass方法,在这个方法可以自定义要加载的类使用的类加载器。

第二种,使用线程上下文加载器,可以通过java.lang.Thread类的setContextClassLoader()方法来设置当前类使用的类加载器类型。这种叫做SPI( service provider interface)

第三种,这种用的人不多,叫做OSGI,他是以模块化的方式去进行开发,一般用来进行热部署,热更新。

六、说一下堆栈的区别?

6.1 物理地址

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,标记-复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记整理

栈使用的是数据结构中的栈,先进后出的原则。所以栈的物理地址分配是连续的,性能快。

6.2 内存分别

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期确认,大小是固定的。

6.3 存放的内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储。

栈存放局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

PS:
1、静态变量放在方法区,静态的对象还是放在堆。
2、程序的可见度:

  • 堆对于整个应用程序都是共享、可见的。
  • 栈只对于线程是可见的,所以也是线程私有。他的生命周期和线程相同。

6.4 扩展:常量池分类

1.静态常量池

静态常量池是相对于运行时常量池来说的,属于描述class文件结构的一部分

字面量符号引用组成,在类被加载后会将静态常量池加载到内存中也就是运行时常量池

字面量 :文本,字符串以及Final修饰的内容

符号引用 :类,接口,方法,字段等相关的描述信息。

2.运行时常量池

当静态常量池被加载到内存后就会变成运行时常量池。

也就是真正的把文件的内容落地到JVM内存了

3.字符串常量池

设计理念:字符串作为最常用的数据类型,为减小内存的开销,专门为其开辟了一块内存区域(字符串常量池)用以存放。

JDK1.6及之前版本,字符串常量池是位于永久代(相当于现在的方法区)。

JDK1.7之后,字符串常量池位于Heap堆中。
image.png

4.面试常问点

下列三种操作最多产生哪些对象?

1.直接赋值
String a ="aaaa";

解析:

最多创建一个字符串对象。

首先“aaaa”会被认为字面量,先在字符串常量池中查找(.equals()),如果没有找到,在堆中创建“aaaa”字符串对象,并且将“aaaa”的引用维护到字符串常量池中(实际是一个hashtable结构,存放key-value结构数据),再返回该引用;如果在字符串常量池中已经存在“aaaa”的引用,直接返回该引用。

2.new String()
String a =new String("aaaa");

解析:

最多会创建两个对象。

首先“aaaa”会被认为字面量,先在字符串常量池中查找(.equals()),如果没有找到,在字符串常量池中创建“aaaa”字符串对象,然后再在堆中创建一个“aaaa”对象,返回后面“aaaa”的引用;

3.intern()

String s1 = new String("yzt");
String s2 = s1.intern();
System.out.println(s1 == s2); //false

解析:

String中的intern方法是一个 native 的方法,当调用 intern方法时,如果常量池已经包含一个等于此String对象的字符串(用equals(object)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将s1 复制到字符串常量池里)

七、运行时数据区

image.png

7.1 方法区

(1)方法区是各个线程共享的内存区域,在虚拟机启动时创建
(2)虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来
(3)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
(4)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常(OOM)

注意:JVM运行时数据区是一种规范,真正的实现在JDK 8中就是Metaspace,在JDK6或7中就是Perm Space

7.2 Heap(堆)

(1)Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享
但是排除一种情况TLAB(hread Local Allocation Buffer),JVM默认为每个线程在Eden上开辟一个buffer区域,用来加速对象的分配。所以对象优先会在TLAB上分配,但是TLAB空间通常会比较小。大对象还是在共享区域分配。
(2)Java对象实例以及数组都在堆上分配。

7.3 虚拟机栈

(1)虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建
(2)每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。

7.3.1 栈帧又是什么呢?

每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间。

每个栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向运行时常量池的引用(A reference to the run-time constant pool)、方法返回地址(Return Address)和附加信息。
image.png
1、局部变量表
方法中定义的局部变量以及方法的参数存放在这张表中。其中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。

2、操作数栈
以压栈和出栈的方式存储操作数的。

3、动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。**简单的说,动态链接是为了支持方法的动态调用过程。**动态链接将这些符号方法引用转换为具体的方法引用。

4、方法返回地址
当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

动态链接是为了支持方法的动态调用过程 。动态链接将这些符号方法引用转换为具体的方法引用。而符号引用转变为直接引用,为了支持java的多态。

八、堆为什么进行分代设计

image.png
98%对象朝生夕死,只有2%需要垃圾回收。所有分配old存放大对象,young存放小对象。
两个Survivor区最大的好处就是解决了碎片化。假设现在只有一个Survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。所以设置一个Survivor space是空的,另一个非空的Survivor space无碎片。

九、对象的创建以及分配过程

9.1 对象的生命周期

image.png

9.1.1 创建阶段

(1)为对象分配存储空间
(2)开始构造对象
(3)从超类到子类对static成员进行初始化
(4)超类成员变量按顺序初始化,递归调用超类的构造方法
(5)子类成员变量按顺序初始化,子类构造方法调用,并且一旦对象被创建,并被分派给某些变量赋值,这个对象的状态就切换到了应用阶段

9.1.2 应用阶段

(1)系统至少维护着对象的一个强引用(Strong Reference)
(2)所有对该对象的引用全部是强引用(除非我们显式地使用了:软引用(Soft Reference)、弱引用(Weak Reference)或虚引用(Phantom Reference))

引用的定义:
1.我们的数据类型必须是引用类型
2.我们这个类型的数据所存储的数据必须是另外一块内存的起始地址

image.png

9.1.2.1 扩展:4种引用

1.强引用
JVM内存管理器从根引用集合(Root Set)出发遍寻堆中所有到达对象的路径。当到达某对象的任意路径都不含有引用对象时,对这个对象的引用就被称为强引用。
2.软引用
软引用是用来描述一些还有用但是非必须的对象。对于软引用关联的对象,在系统将于发生内存溢出异常之前,将会把这些对象列进回收范围中进行二次回收。一般适用场景,占用内存较大的对象并且生命周期比较长的,不是频繁使用的。比如图片对象。考虑是否回收
问题:软引用可能会降低应用的运行效率与性能。比如:软引用指向的对象如果初始化很耗时,或者这个对象在进行使用的时候被第三方施加了我们未知的操作。

用处: 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建

(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

3.弱引用
弱引用(Weak Reference)对象与软引用对象的最大不同就在于:GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于Weak引用对象, GC总是进行回收。因此Weak引用对象会更容易、更快被GC回收。
4.虚引用
也叫幽灵引用和幻影引用,为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。也就是说,如果一个对象被设置上了一个虚引用,实际上跟没有设置引用没有任何的区别。一般与finalize()搭配使用。

软引用伪代码Demo:

public class SoftReferenceDemo {
    public static void main(String[] args) {
        //。。。一堆业务代码

        Worker a = new Worker();
		//。。业务代码使用到了我们的Worker实例

        // 使用完了a,将它设置为soft 引用类型,并且释放软引用;
        SoftReference sr = new SoftReference(a);
        a = null;
		//这个时候他是有可能执行一次GC的
        System.gc();

        // 下次使用时
        if (sr != null) {
            a = (Worker) sr.get();
            System.out.println(a );
        } else {
            // GC由于内存资源不足,可能系统已回收了a的软引用,
            // 因此需要重新装载。
            a = new Worker();
            sr = new SoftReference(a);
        }
    }


}

弱引用伪代码Demo:

public class WeakReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        //100M的缓存数据
        byte[] cacheData = new byte[100 * 1024 * 1024];
        //将缓存数据用软引用持有
        WeakReference<byte[]> cacheRef = new WeakReference<>(cacheData);
        System.out.println("第一次GC前" + cacheData);
        System.out.println("第一次GC前" + cacheRef.get());
        //进行一次GC后查看对象的回收情况
        System.gc();
        //因为我们不确定我们的System什么时候GC
        Thread.sleep(1000);
        System.out.println("第一次GC后" + cacheData);
        System.out.println("第一次GC后" + cacheRef.get());

        //将缓存数据的引用去除
        cacheData = null;
        System.gc();    //默认通知一次Full  GC
        
        //等待GC
        Thread.sleep(500);
        System.out.println("第二次GC后" + cacheData);
        System.out.println("第二次GC后" + cacheRef.get());
    }
}

虚引用伪代码Demo:

public class PhantomReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        Object value = new Object();
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        Thread thread = new Thread(() -> {
            try {
                int cnt = 0;
                WeakReference<byte[]> k;
                while ((k = (WeakReference) referenceQueue.remove()) != null) {
                    System.out.println((cnt++) + "回收了:" + k);
                }
            } catch (InterruptedException e) {
                //结束循环
            }
        });
        thread.setDaemon(true);
        thread.start();


        Map<Object, Object> map = new HashMap<>();
        for (int i = 0; i < 10000; i++) {
            byte[] bytes = new byte[1024 * 1024];
            WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes, referenceQueue);
            map.put(weakReference, value);
        }
        System.out.println("map.size->" + map.size());


    }
}

finalize方法代码Demo:

public class Finalize {

    private static Finalize save_hook = null;//类变量

    public void isAlive() {
        System.out.println("我还活着");
    }

    @Override
    public void finalize() {
        System.out.println("finalize方法被执行");
        Finalize.save_hook = this;
    }

    public static void main(String[] args) throws InterruptedException {
        save_hook = new Finalize();//对象
        //对象第一次拯救自己
        save_hook = null;
        System.gc();
        //暂停0.5秒等待他
        Thread.sleep(500);
        if (save_hook != null) {
            save_hook.isAlive();
        } else {
            System.out.println("好了,现在我死了");
        }

        //对象第二次拯救自己
        save_hook = null;
        System.gc();
        //暂停0.5秒等待他
        Thread.sleep(500);
        if (save_hook != null) {
            save_hook.isAlive();
        } else {
            System.out.println("我终于死亡了");
        }
    }
}

9.1.3 不可见阶段

不可见阶段的对象在虚拟机的对象根引用集合中再也找不到直接或者间接的强引用,最常见的就是线程或者函数中的临时变量。程序不再持有对象的强引用,但是某些类的静态变量或者JNI是有可能持有的 。

9.1.4 不可达阶段

对象不再被任何强引用持有,GC发现该对象已经不可达

9.2 对象的垃圾回收流程

一般情况下,新创建的对象都会被分配到Young区Eden部分,一些特殊的大的对象会直接分配到Old区
23.png
Eden满了,去Survivor区的“From”区,自从去了Survivor区的“From”区与“To”区,来回倒腾。直到15(cms 6)次后,去到Old区。> 现在只有cms还有老年代说法,所以上图中的full gc统指所有垃圾回收器的老年代。

9.2.1 为什么两个Survivor区?

解决了碎片化
假设现在只有一个Survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
所以解决方案:永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。

十、访问对象有哪几种方式

10.1 句柄池访问

image.png

使用句柄访问对象,会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体)
的内存地址
访问类型数据的内存地址(类信息,方法类型信息)。

对象实例数据一般在堆中开辟,类型数据一般储存在方法区中。

优点 :reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针,而reference本身不需要改变。

缺点 :增加了一次指针定位的时间开销。

10.2 直接指针访问对象

image.png

直接指针访问方式指reference中直接储存对象实例数据,对应的类型数据内存地址在对象实例中存储。

优点 :节省了一次指针定位的开销。

缺点 :在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改。

十一、对象的内存布局

image.png
对齐填充,目的 提高CPU访问数据的效率 ,主要针对会存在该实例对象数据跨内存地址区域存储的情况。

例如:在没有对齐填充的情况下,内存地址存放情况如下:
image.png
因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值。

那么在有对齐填充的情况下,内存地址存放情况是这样的:
image.png
现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。

十二、方法区与元数据区以及持久代到底是什么关系?

Full GC = young GC + Old GC + Meta Space GC

规范:方法区
实现

  • JDK1.7之前,永久代 、持久代统称PermSpace ,不能确定类的总数,常量池大小,方法的数量
  • JDK1.8以及其之后,元空间、元数据区统称MetaSpace,JVMTI开后门

比如,16G,2G堆内存,如果是Perm Space只能使用2G,但是MetaSpace能使用16G,其中堆内存2G也能使用。因为如下图,方法区的静态变量和字符串常量池存放在堆中。
image.png

十三、垃圾收集算法

13.1 如何确定一个对象是垃圾?

13.1.1 引用计数法(循环引用)

对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。

弊端:如果AB相互持有引用,导致永远不能被回收。 循环引用–>内存泄露–>内存溢出。如下图:
16461374670483019208ffy

13.1.2 可达性分析/根搜索算法(推荐)

通过GC Root的引用,开始向下寻找,看某个对象是否可达。引用出一条单向的引用链,而在这个单向的引用链上的对象,我们称之为GC的可达对象,不在引用链上的对象,我们称之为垃圾。
image.png

能作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等。
官方介绍以下4种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

什么时候会垃圾回收?

GC是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。当然,我们可以手动进行垃圾回收,比如调用System.gc()方法通知JVM进行一次垃圾回收,但是具体什么时刻运行也无法控制。也就是说System.gc()只是通知要回收,什么时候回收由JVM决 定。但是不建议手动调用该方法,因为GC消耗的资源比较大

触发GC场景:
(1)(Eden区或者Survivor区)Young不够用了
(2)Old空间不够用了
(3)方法区空间不够用了
(4)System.gc()

13.2 垃圾回收器算法

13.2.1 标记-清除(Mark-Sweep)

标记
找出内存中所有的存活对象,并且把它们标记出来。

此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时

image.png
清除
清除不被标记需要回收的对象,释放出对应的内存空间。
image.png

缺点

(1)标记和清除两个过程都比较耗时,效率不高
(2)会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

13.2.2 标记-复制(Mark-Copying)

将内存划分为两块相等的区域,每次只使用其中一块。如下图:
image.png

当其中一块内存使用完了,就将还存活的对象复制到另外一块上面,然后把已经使用过的内存空间一次清除掉。
image.png

缺点:

1、空间利用率降低。
2、需额外的空间进行分配担保,以应对被使用的内存中所有对象都有100%存活的极端情况,所以老年代一般不用这种算法。

13.2.3 标记-整理(Mark-Compact)

与标记-清除一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。与标记-复制来说,少了一个保留区。
image.png

让所有存活的对象都向一端移动,清理掉边界意外的内存。
image.png

13.2.4 分代收集算法

  • Young区:标记-复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高)。默认S1和S2就是一样大小。
  • Old区:标记-清除标记-整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)。

13.2.5 增量回收算法(已淘汰)

垃圾回收其实就是对不需要的内存对象进行清理,前面提到的GC算法,无论哪种,基本都是过一段时间对所有的内存空间对象进行一次大扫除。 这种的GC缺点是一旦开始启动,管理程序可能就停止了,表现就是可能好多程序都没响应。可在服务端,这是大忌。增量式(incremental)出现就是解决这个问题的,这种垃圾回收采用和应用程序交替进行的方式来工作,表现就像是GC在不断的定时迭加操作。从而尽量减轻应用程序的停止时间,这就是增量式回收的特点。在增量式回收里,比较容易接触到的就是三色标记算法。

13.2.5.1 三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。这里引入“三色标记”来给大家解释下,把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

黑色:

表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。它是安全存活的,如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。

灰色:

表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。

白色:

表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

步骤:
1.初始时,所有对象放在【白色集合】;
2.将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
3.从灰色集合中获取对象:
4.将本对象引用到的其他对象全部挪到 【灰色集合】中;
5.将本对象挪到【黑色集合】里面。

重复步骤3.4,直至【灰色集合】为空时结束。
结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

16522703100363012464ffy

多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gc root引用的对象之前又被扫描过 (被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。

漏标-读写屏障
漏标只有同时满足以下两个条件时才会发生:

条件一:灰色对象断开了白色对象的引用,即灰色对象原来成员变量的引用发生了变化。

条件二:黑色对象重新引用了该白色对象,即黑色对象成员变量增加了新的引用。

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)

增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。

原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

写屏障实现原始快照(SATB): 当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来。

写屏障实现增量更新: 当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来。

十四、什么是STW(stop the world)?

Stop-The-World,简称 STW。在垃圾回收算法执行过程中,将JVM内存冻结,停顿的一种状态

在STW情况下,容易出现两种现象:
1、该回收的对象没有被回收
2、不该回收的对象被回收了

在STW状态下,所有的线程都是停止运行的,垃圾回收线程除外,中断了的线程直到GC线程结束才会继续任务。

STW是不可避免的,垃圾回收算法的执行一定会出现STW,而我们最好的解决办法就是减少停顿的时间。GC各种算法的优化重点就是为了减少STW,这也是JVM调优的重点。人类感知不200ms~500ms停顿,所以一般电商类项目500ms内足够,计算类项目1s内足够。

十五、你接触过哪些垃圾收集器,聊一下

收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
image.png

15.1 Serial

Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK1.3.1之前)是虚拟机新生代收集的唯一选择。

它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程。
在这里插入图片描述

优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:复制算法
适用范围:新生代
应用:Client模式下的默认新生代收集器

15.2 Serial Old

Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,不同的是采用"标记-整理算法",运行过程和Serial收集器一样。image.png

早期的垃圾收集器为什么要设计成单线程? 单核CPU

15.3 ParNew

Serial收集器的多线程版本

优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。
算法:复制算法
适用范围:新生代
应用:运行在Server模式下的虚拟机中首选的新生代收集器

image.png

15.4 Parallel Scavenge

新生代收集器,它使用标记-复制算法的收集器,又是并行的多线程收集器,看上去和ParNew一样,但是Parallel Scanvenge更关注系统的吞吐量

吞吐量
单位时间内接受请求并响应的数量。

吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%。

吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用CPU资源,尽快完成程序的运算任务。

15.5 Parallel Old

Parallel Scavenge收集器的老年代版本,使用并行多线程标记-整理算法进行垃圾回收,也是更加关注系统的吞吐量

总结
现在很多比较老的项目使用Parallel Scavenge(PS) + Parallel Old(PO)垃圾回收器,一是它本身已达到最优配置,二是这种方案既能保证较高吞吐量,也能保证较短的STW。比如,吞吐量95%-96%以上 ,停顿时间500ms内, full GC不能小于一天/次。

以下垃圾回收器,并行处理业务代码 + 垃圾收集线程的并发类垃圾收集器

15.6 CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取 最短回收停顿时间为目标的收集器。

采用的是标记-清除算法,整个过程分为4步:

(1)初始标记 CMS initial mark     标记GC Roots直接关联对象,不用Tracing。速度很快,不耗时,STW。
(2)并发标记 CMS concurrent mark  进行GC Roots Tracing。耗时,并发。
(3)重新标记 CMS remark           修改并发标记因用户程序变动的内容。不耗时,STW。
(4)并发清除 CMS concurrent sweep 清除不可达对象回收空间,同时有新垃圾产生,留着下次清理称为浮动垃圾。耗时,并发。

image.png
由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。

优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量

15.7 G1(Garbage-First)

JDK7的最后的维护版,JDK8推荐使用,JDK9默认

优先回收垃圾价值高的区域,某种程度上解决空间碎片的问题。一定程度上,要比CMS的停顿时间短,想多短就多短。

使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region)2048个。

Region
1、每个Region大小都是一样的,可以是1M到32M之间的数值,但是必须保证是2的n次幂
2、如果对象太大,一个Region放不下,且超过Region大小的50%,那么就会直接放到H中
3、设置Region大小:-XX:G1HeapRegionSize=M
4、所谓Garbage-Frist,其实就是
优先回收垃圾最多的Region区域

特点
(1)分代收集。仍然保留了分代的概念,但新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合。
(2)空间整合。整体上属于标记-整理算法,不会导致空间碎片。
(3)可预测的停顿。比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
image.png
工作过程可以分为4步:

初始标记(Initial Marking)      标记GC Roots直接关联的对象,并且修改TAMS的值,需要暂停用户线程。速度快,STW。
并发标记(Concurrent Marking)   从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行。并发。
最终标记(Final Marking)        修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程。速度快,STW。
筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。并发。

image.png

15.8 ZGC

JDK11新引入的ZGC收集器,不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念。会分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题。只能在64位的linux上使用,目前用得还比较少。

官方给出特性:
(1)可以达到10ms以内的停顿时间要求
(2)支持TB级别的内存
(3)堆内存变大后停顿时间还是在10ms以内

十六、什么是记忆集?

当我们进行young gc时,gc roots除了常见的栈引用、静态变量、常量、锁对象、class对象这些常见的之外,如果 老年代有对象引用了我们的新生代对象 ,那么老年代的对象也应该加入gc roots的范围中,但是如果每次进行young gc我们都需要扫描一次老年代的话,那我们进行垃圾回收的代价实在是太大了,因此我们引入了一种叫做记忆集的抽象数据结构来记录这种引用关系。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的数据结构。

16.1 卡表是什么

如果我们不考虑效率和成本问题,我们可以用一个数组存储所有有指针指向新生代的老年代对象。但是如果这样的话我们维护成本就很好,打个比方,假如所有的老年代对象都有指针指向了新生代,那么我们需要维护整个老年代大小的记忆集,毫无疑问这种方法是不可取的。因此我们引入了卡表的数据结构。

记忆集是我们针对于跨代引用问题提出的思想,而卡表则是针对于该种思想的具体实现。(可以理解为记忆集是结构,卡表是实现类)

[1字节,00001000,1字节,1字节]

在hotspot虚拟机中,卡表是一个字节数组,数组的每一项对应着内存中的某一块连续地址的区域,如果该区域中有引用指向了待回收区域的对象,卡表数组对应的元素将被置为1,没有则置为0;

(1) 卡表是使用一个字节数组实现:CARD_TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为"卡页"。hotspot使用的卡页是2^9大小,即512字节。

(2) 一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。GC时,只要筛选本收集区的卡表中变脏的元素加入GC Roots里。

卡表的使用图例
image.png

并发标记的时候,A对象发生了所在的引用发生了变化,所以A对象所在的块被标记为脏卡
image.png

继续往下到了重新标记阶段,修改对象的引用,同时清除脏卡标记。
image.png
卡表其他作用:
老年代识别新生代的时候,对应的card table被标识为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的)。

十七、如何选择合适的垃圾收集器

  • 优先调整堆的大小让服务器自己来选择
  • 如果内存小于100M,使用串行收集器。Serial 、Serial Old
  • 如果是单核,并且没有停顿时间要求,使用串行或JVM自己选。Serial 、Serial Old
  • 如果允许停顿时间超过1秒,选择并行或JVM自己选。ParNew、Parallel Scavenge、Parallel Old
  • 如果响应时间最重要,并且不能超过1秒,使用并发收集器。CMS、G1

十八、JVM常用参数有哪些?

18.1 JVM参数

18.1.1 标准参数

-version
-help
-server
-cp

18.1.2 -X参数

非标准参数,也就是在JDK各个版本中可能会变动。

-Xint     解释执行
-Xcomp    第一次使用就编译成本地代码
-Xmixed   混合模式,JVM自己来决定

image.png

18.1.3 -XX参数

使用得最多的参数类型,非标准化参数,相对不稳定,主要用于JVM调优和Debug。

a.Boolean类型
格式:-XX:[+-]<name>            +-表示启用或者禁用name属性
比如:-XX:+UseConcMarkSweepGC   表示启用CMS类型的垃圾回收器
	 -XX:+UseG1GC              表示启用G1类型的垃圾回收器
	 
b.Boolean类型
格式:-XX<name>=<value>表示name属性的值是value
比如:-XX:MaxGCPauseMillis=500   

18.1.4 其他参数

-XX类型的参数简写。

-Xms1000M等价于-XX:InitialHeapSize=1000M
-Xmx1000M等价于-XX:MaxHeapSize=1000M
-Xss100等价于-XX:ThreadStackSize=100

18.1.5 查看参数

java -XX:+PrintFlagsFinal -version > flags.txt
image.png

image.png

值得注意的是"="表示默认值,":="表示被用户或JVM修改后的值
要想查看某个进程具体参数的值,可以使用jinfo。

18.2 设置参数的常见方式

  • 开发工具中设置比如IDEA,eclipse
  • 运行jar包的时候:java -XX:+UseG1GC xxx.jar
  • web容器比如tomcat,可以在脚本中的进行设置
  • 通过jinfo实时调整某个java进程的参数(参数只有被标记为manageable的flags可以被实时修改)

实践和单位换算

1Byte(字节)=8bit(位)
1KB=1024Byte(字节)
1MB=1024KB
1GB=1024MB
1TB=1024GB

比如,

(1)设置堆内存大小和参数打印
-Xmx100M -Xms100M -XX:+PrintFlagsFinal
(2)查询+PrintFlagsFinal的值
:=true
(3)查询堆内存大小MaxHeapSize
:= 104857600
(4)换算
104857600(Byte)/1024=102400(KB)
102400(KB)/1024=100(MB)
(5)结论
104857600是字节单位

18.3 常用参数含义

参数含义说明
-XX:CICompilerCount=3最大并行编译数如果设置大于1,虽然编译速度会提高,但是同样影响系统稳定性,会增加JVM崩溃的可能
-XX:InitialHeapSize=100M初始化堆大小简写-Xms100M
-XX:MaxHeapSize=100M最大堆大小简写-Xms100M
-XX:NewSize=20M设置年轻代的大小
-XX:MaxNewSize=50M年轻代最大大小
-XX:OldSize=50M设置老年代大小
-XX:MetaspaceSize=50M设置方法区大小
-XX:MaxMetaspaceSize=50M方法区最大大小
-XX:+UseParallelGC使用UseParallelGC新生代,吞吐量优先
-XX:+UseParallelOldGC使用UseParallelOldGC老年代,吞吐量优先
-XX:+UseConcMarkSweepGC使用CMS老年代,停顿时间优先
-XX:+UseG1GC使用G1GC新生代,老年代,停顿时间优先
-XX:NewRatio新老生代的比值比如-XX:Ratio=4,则表示新生代:老年代=1:4,也就是新生代占整个堆内存的1/5
-XX:SurvivorRatio两个S区和Eden区的比值比如-XX:SurvivorRatio=8,也就是(S0+S1):Eden=2:8,也就是一个S占整个新生代的1/10
-XX:+HeapDumpOnOutOfMemoryError启动堆内存溢出打印当JVM堆内存发生溢出时,也就是OOM,自动生成dump文件
-XX:HeapDumpPath=heap.hprof指定堆内存溢出打印目录表示在当前目录生成一个heap.hprof文件
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:g1-gc.log打印出GC日志可以使用不同的垃圾收集器,对比查看GC情况
-Xss128k设置每个线程的堆栈大小经验值是3000-5000最佳
-XX:MaxTenuringThreshold=6提升年老代的最大临界值默认值为 15
-XX:InitiatingHeapOccupancyPercent启动并发GC周期时堆内存使用占比G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45.
-XX:G1HeapWastePercent允许的浪费堆空间的占比默认是10%,如果并发标记可回收的空间小于10%,则不会触发MixedGC。
-XX:MaxGCPauseMillis=200msG1最大停顿时间暂停时间不能太小,太小的话就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。
-XX:ConcGCThreads=n并发垃圾收集器使用的线程数量默认值随JVM运行的平台不同而不同
-XX:G1MixedGCLiveThresholdPercent=65混合垃圾回收周期中要包括的旧区域设置占用率阈值默认占用率为 65%
-XX:G1MixedGCCountTarget=8设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数默认8次混合垃圾回收,混合回收的目标是要控制在此目标次数以内
-XX:G1OldCSetRegionThresholdPercent=1描述Mixed GC时,Old Region被加入到CSet中默认情况下,G1只把10%的Old Region加入到CSet中

18.4 你调优过哪些参数?

18.4.1 适用所有GC

-Xmx:设置堆的最大值,一般为操作系统的 2/3 大小 (MaxHeapSize)

-Xms:设置堆的初始值,一般设置成和 Xmx 一样的大小来避免动态扩容。 (InitialHeapSize)

如果-Xmx和-Xms不一样,项目启动直接Full GC

-Xmn:表示年轻代的大小,默认新生代占堆大小的 1/3。高并发、对象快消亡场景可适当加大这个区域,对半,或者更多,都是可以的。但是在 G1 下,就不用再设置这个值了,它会自动调整

-Xss:用于设置栈的大小,默认为 1M,如果代码中局部变量不多,可设置成256K节约空间。

-XX:+UseTLAB 使用TLAB,默认打开

-XX:+PrintTLAB 打印TLAB的使用情况

-XX:TLABSize 设置TLAB大小

-XX:+PrintGC 查看GC基本信息

-XX:+PrintGCDetails 查看GC详细信息

-XX:+PrintHeapAtGC 每次一次GC后,都打印堆信息

-XX:+PrintFlagsFinal -XX:+PrintFlagsInitial 打印所有的JVM参数、查看所有JVM参数启动的初始值(必须会用)

-XX:MaxTenuringThreshold 升代(分代)年龄,这个值在CMS 下默认为 6,G1 下默认为 15,这个值和我们前面提到的对象提升有关,改动效果会比较明显。对象的年龄分布可以使用 -XX:+PrintTenuringDistribution 打印,如果后面几代的大小总是差不多,证明过了某个年龄后的对象总能晋升到老生代,就可以把晋升阈值设小。

18.4.2 Parallel GC 常用参数

-XX:SurvivorRatio 设置Eden大小与Survivor大小之间的比率。默认情况下,此选项设置为8

-XX:PreTenureSizeThreshold 对象到达一定的限定值的时候 会直接进入老年代。大于这个值的参数直接在老年代分配

-XX:+ParallelGCThreads 并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同

-XX:+UseAdaptiveSizePolicy 自动选择各区大小比例

18.4.3 CMS GC 常用参数

-XX:+UseConcMarkSweepGC 启用CMS垃圾回收器

-XX:CMSInitiatingOccupancyFraction 并发失败的模式。使用多少比例的老年代后开始CMS收集,默认是68%(近似值),如果频繁发生SerialOld卡顿,应该调小,(频繁CMS回收)

-XX:+UseCMSCompactAtFullCollection FGC时进行压缩

-XX:CMSFullGCBeforeCompaction 多少次FGC之后进行压缩

-XX:+CMSClassUnloadingEnabled 使用并发标记扫描(CMS)垃圾收集器时,启用类卸载。默认情况下启用此选项。

18.4.4 G1 常用参数

-XX:+UseG1GC 启用G1垃圾收集器

-XX:MaxGCPauseMillis 设置最大GC暂停时间的目标(以毫秒为单位)。这是一个软目标,并且JVM将尽最大的努力(G1会尝试调整Young区的块数来)来实现它。默认情况下,没有最大暂停时间值。

-XX:GCPauseIntervalMillis GC的间隔时间

-XX:+G1HeapRegionSize 4C8G起步。单个Region大小,取值是1M-32M,建议逐渐增大该值,1 2 4 8 16 32。随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长

-XX:G1NewSizePercent 新生代最小比例,默认为1/2000

-XX:G1MaxNewSizePercent 新生代最大比例,默认为60%

-XX:GCTimeRatioGC 时间建议比例,G1会根据这个值调整堆空间

-XX:ConcGCThreads 初始标记线程数量

-XX:InitiatingHeapOccupancyPercent 启动G1的堆空间占用比例,根据整个堆的占用而触发并发GC周期

十九、JVM常用命令有哪些

19.1 jps

查看java进程

image.png

19.2 jinfo

实时查看和调整JVM配置参数

(1)查看某个java进程的name属性的值。
image.png
(2)修改。只能修改manageable的参数。

(3)查看曾经赋过值的一些参数。
image.png

19.3 jstat

(1)查看虚拟机性能统计信息

(2)查看类装载信息

jstat -class PID 1000 10 ,查看某个java进程的类装载信息,每1000毫秒输出一次,共输出10次。
image.png

(3)查看垃圾收集信息

jstat -gc PID 1000 10
image.png

19.4 jstack

(1)查看线程堆栈信息

jstack PID
image.png

19.6 jmap

(1)生成堆转储快照
(2)打印出堆内存相关信息

jmap -heap PID
image.png

(3)dump出堆内存相关信息

jmap -dump:format=b,file=heap.hprof PID
image.png

一般在开发中,JVM参数可以加下面两句,这样内存溢出时,会自动dump出该文件。生产环境不建议使用。

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap.hprof

19.7 JVM自带的可视化工具

19.7.1 JConsole

JConsole工具是JDK自带的图形化性能监控工具。并通过JConsole工具, 可以查看Java应用程序的运行概况, 监控堆信息、 元空间使用情况及类的加载情况等。

JConsole程序在%JAVA_HOM E%/bin目录下

或者你可以直接在命令行对他进行打印
image.png

会显示如下界面:

image.png

19.7.1.1 JConsole的连接方式

本地连接(小型单体)

Jconsole会在本地自动寻找当前的可监控进程,所以我们可以只要本地启动项目,就可以自动匹配并点击进去。

image.png

远程连接(生产环境)

  1. 设置被监控的Java虚拟机启动的參数,一般的情况下,会有下面三个參数,各自是:

-Dcom.sun.management.jmxremote.port=1090

-Dcom.sun.management.jmxremote.ssl=false

-Dcom.sun.management.jmxremote.authenticate=false

也就是说,你需要在启动参数后面加上这几个参数

image.png

被监控的虚拟机启动以后,我们就能够其他电脑上通Jconsole进行远程连接。

连接的过程例如以下:

1.打开cmd,输入jconsole,就会出现jconsole控制台,

然后,我们输入要被监控的Java虚拟机的IP地址和port号,如果输入正确,连接button就上生效如果设计的监控port号为8082,连接的IP为:10.20.618.11(这个需要你防火墙以及端口都处于开放状态),例如以下图所看到的:

image.png

点击连接后,就会进入到正常的显示界面,说明就连接成功了。

19.7.1.2 JConsole的显示界面

image.png

概述 :记录了“堆内存使用情况”、“线程”、“类”、“CPU使用情况”共四个资源的实时情况;

image.png

内存 :可以选择查看“堆内存使用情况”、“非堆内存使用情况”、“内存池”、“PS Eden Space”等内存占用的实时情况;界面右下角还有图形化的堆一级、二级、三级缓存(从左到右)占用情况,当然,如果三级缓存被全部占用也就是很可能内存溢出啦!这时可以去查看服务器的tomcat日志,应该会有“outofmemory"的异常日志信息。界面右上角处还提供了一个“执行GC”的手动垃圾收集功能,这个也很实用~而且界面下方还有详细的GC信息记录。,整个界面提供了关于垃圾收集必须的各项基础指标查询。

image.png

线程 :界面上部显示实时线程数目。下部还能查看到详细的每个进程及相应状态、等待、堆栈追踪等信息;

image.png

并且在右下角,我们还可以检测死锁的情况。如果当前线程没有出现死锁,那么会显示未出现死锁。

image.png

但是如果出现了死锁,这里也会进行相应的检测。会直接显示死锁的页面,并且我们可以通过点击对应的线程来查看死锁的信息。

image.png

:显示“已装入类的数目”、“已卸载类的数目”信息;

image.png

VM摘要 :显示服务器详细资源信息,包括:线程、类、OS、内存等;

image.png

image.png

MBean : 可在此页进行参数的配置。

MBean就是被JMX管理的资源。 一般有两种类型的MBean,标准的和动态的。 标准类型的MBean最简单,它能管理的资源(包括属性,方法,时间)必须定义在接口中,然后MBean必须实现这个接口。它的命名也必须遵循一定的规范,例如我们的MBean为User,则接口必须为UserMBean。 动态MBean必须实现javax.management.DynamicMBean接口,所有的属性,方法都在运行时定义。

这个一般情况下互联网交互式企业级开发用到的可能性没有那么高。因为现在JMX架构用得没那么多。

19.7.2 jvisualvm

官网:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/jvisualvm.html

Java VisualVM 是一个直观的图形用户界面,可在基于 Java 技术的应用程序(Java 应用程序)在指定的 Java 虚拟机 (JVM) 上运行时提供有关它们的详细信息。

Java VisualVM 将多个监控、故障排除和分析实用程序组合到一个工具中。Java VisualVM 使开发人员能够生成和分析堆转储、跟踪内存泄漏、执行和监视垃圾收集以及执行轻量级内存和 CPU 分析。

使用以下命令启动 Java VisualVM:

% jvisualvm <选项>

19.7.2.1 监控Java进程方式

监控本地:可以监控本地的java进程的CPU,类,线程等

监控远端

(1)在visualvm中选中“远程”,右击“添加”

(2)主机名上写服务器的ip地址,比如39.100.39.63,然后点击“确定”

(3)右击该主机"39.100.39.63",添加“JMX”,也就是通过JMX技术具体监控远端服务器哪个Java进程

(4)要想让服务器上的tomcat被连接,需要改一下Catalina.sh这个文件

注意下面的8998不要和服务器上其他端口冲突

JAVA_OPTS="$JAVA_OPTS -Dcom.sun.management.jmxremote -
Djava.rmi.server.hostname=39.100.39.63 -Dcom.sun.management.jmxremote.port=8998
-Dcom.sun.management.jmxremote.ssl=false -
Dcom.sun.management.jmxremote.authenticate=true -
Dcom.sun.management.jmxremote.access.file=../conf/jmxremote.access -
Dcom.sun.management.jmxremote.password.file=../conf/jmxremote.password"

(5)在…/conf文件中添加两个文件jmxremote.access和jmxremote.password

jmxremote.access

guest readonly
manager readwrite

jmxremote.password

guest guest
manager manager

授予权限:chmod 600 jmxremot

(6)将连接服务器地址改为公网ip地址

hostname -i   查看输出情况
	172.26.225.240 172.17.0.1
vim /etc/hosts
	172.26.255.240 39.100.39.63

(7)设置上述端口对应的阿里云安全策略和防火墙策略

(8)启动tomcat,来到bin目录

./startup.sh

(9)查看tomcat启动日志以及端口监听

tail -f ../logs/catalina.out
lsof -i tcp:8080

(10)查看8998监听情况,可以发现多开了几个端口

lsof -i:8998    得到PID
netstat -antup | grep PID

(11)在刚才的JMX中输入8998端口,并且输入用户名和密码则登录成功

端口:8998
用户名:manager
密码:manager

二十、你会估算GC频率吗?

正常情况我们应该根据我们的系统来进行一个内存的估算,这个我们可以在测试环境进行测试,最开始可以将内存设置的大一些,比如4G这样,当然这也可以根据业务系统估算的。比如从数据库获取一条数据占用128个字节,需要获取1000条数据,那么一次读取到内存的大小就是(128 B/1024 Kb/1024M ) * 1000 = 0.122M,那么我们程序可能需要并发读取,比如每秒读取100次,那么内存占用就是0.122 * 100 = 12.2M,如果堆内存设置1个G,那么年轻代大小大约就是333M,那么333M*80%/12.2M =21.84s,也就是说我们的程序几乎每分钟进行两到三次youngGC。这样可以让我们对系统有一个大致的估算。

二十一、JVM性能优化

21.1 内存分配

21.1.1 亿级流量系统

image.png

亿级流量系统,其实就是每天点击量在亿级的系统,根据淘宝的一个官方的数据分析。

每个用户一次浏览点击20~40次之间,推测出每日活跃用户(日活用户)在500万左右。

同时结合淘宝的一个点击数据,可以发现,能够付费的也就是橙色的部分(cart)的用户,比例只有10%左右。

90%的用户仅仅是浏览,那么我们可以通过图片缓存、Redis缓存等技术,我们可以把90%的用户解决掉。

10%的付费用户,大概算出来是每日成交50万单左右。
image.png
GC 预估调优

如果是普通业务,一般处理时间比较平缓,大概在3,4个小时处理,算出来每秒只有几十单,这个一般的应用可以处理过来(不需要JVM预估调优)。

另外电商系统中有大促场景(秒杀、限时抢购等),一般这种业务是几秒或者几分钟。我们算出来大约每秒2000单左右的数据,承受大促场景的使用4台服务器(使用负载均衡)。每台订单服务器也就是大概500单/秒。

我们测试发现,每个订单处理过程中会占据0.2MB大小的空间(什么订单信息、优惠券、支付信息等等),那么一台服务器每秒产生100M的内存空间,这些对象基本上都是朝生夕死,也就是1秒后都会变成垃圾对象。
image.png

加入我们设置堆的空间最大值为3个G,我们按照默认情况下的设置,新生代1/3的堆空间,老年代2/3的堆空间。Eden:S0:S1=8:1:1。我们推测出,old=2G,Eden=800M,S0=S1=100M。

根据对象的分配原则(对象优先在Eden区进行分配),由此可得,8秒左右Eden区空间满了。

每8秒触发一个MinorGC(新生代垃圾回收),这次MinorGC时,JVM要STW,但是这个时候有100M的对象是不能回收的(线程暂停,对象需要1秒后都会变成垃圾对象),那么就会有100M的对象在本次不能被回收(只有下次才能被回收掉)。

所以经过本次垃圾回收后,本次存活的100M对象会进入S0区,但是由于另外一个JVM对象分配原则(如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄)。所以这样的对象本质上不会进去Survivor区,而是进入老年代。
image.png

所以我们推算,大概每个8秒会有100M的对象进入老年代。大概20*8=160秒,也就是2分40秒左右old区就会满掉,就会触发一次FullGC,一般来说,这次FullGC是可以避免的,同时由于FullGC不单单回收老年代+新生代,还要回收元空间,这些FullGC的时间可能会比较长(老年代回收的朝生夕死的对象,使用标记清除/标记整理算法决定了效率并不高,同时元空间也要回收一次,进一步加大GC时间)。

如何避免没有必要的FullGC?

我们在项目中加入VM参数:

-Xms3072M -Xmx3072M -Xmn2048M  -XX:SurvivorRatio=7
-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M
-XX:MaxTenuringThreshold=2
-XX:ParallelGCThreads=8
-XX:+UseConcMarkSweepGC

1、首先看一下堆空间:old区=1G,Eden区=1.4G,S0=S1=300M
image.png

那么第一点,Eden区大概需要14秒才能填满,填满之后,100M的存活对象会进入S0区(由于这个区域变大,不会触发动态年龄判断)

2、再过14秒,Eden区,填满之后,还是剩余100M的对象要进入S1区。但是由于原来的100M已经是垃圾了(过了14秒了),所以S1也只会有Eden区过来的100M对象,S0的100M已经被回收,也不会触发动态年龄判断。
image.png

3、反反复复,这样就没有对象会进入old区,就不会触发FullGC,同时我们的MinorGC的频次也由之前的8秒变为14秒,虽然空间加大,但是换来的还是GC的总时间会减少。

image.png

4、-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M。栈一般情况下很少用到1M。所以为了线程占用内存更少,我们可以减少到256K。元空间一般启动后就不会有太多的变化,我们可以设定为128M,节约内存空间。

5、-XX:MaxTenuringThreshold=2。这个是分代年龄(年龄为2就可以进入老年代),因为我们基本上都使用的是Spring架构,Spring中很多的bean是长期要存活的,没有必要在Survivor区过渡太久,所以可以设定为2,让大部分的Spring的内部的一些对象进入老年代。

6、-XX:ParallelGCThreads=8。线程数可以根据你的服务器资源情况来设定(要速度快的话可以设置大点,根据CPU的情况来定,一般设置成CPU的整数倍)

image.png

7、-XX:+UseConcMarkSweepGC 因为这个业务响应时间优先的,所以还是可以使用CMS垃圾回收器或者G1垃圾回收器。

image.png

21.1.2 一般流量电商

正常情况下不需要设置,如果促销或者秒杀等特殊场景需要手动设置。举例,每台机器配置2c4G,峰值以每秒3000笔订单为例,整个过程持续60秒。
image.png

21.2 内存溢出(OOM)

一般会有两个原因:
(1)大并发情况下
(2)内存泄露导致内存溢出

举例,大并发情况下的秒杀,可通过一下方案解决OOM问题:

  • 浏览器缓存、本地缓存、验证码
  • CDN静态资源服务器
  • 集群+负载均衡
  • 动静态资源分离、限流[基于令牌桶、漏桶算法]
  • 应用级别缓存、接口防刷限流、队列、Tomcat性能优化
  • 异步消息中间件
  • Redis热点数据对象缓存
  • 分布式锁、数据库锁
  • 5分钟之内没有支付,取消订单、恢复库存等

21.2.1 举例排查过程

(1)启动

java -jar -Xms1000M -Xmx1000M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=jvm.hprof  jvm-case-0.0.1-SNAPSHOT.jar

(2)使用jmeter模拟10000次并发:接口39.100.39.63:8080/tl

(3)top命令查看

top
top -Hp PID

(4)jstack查看线程情况,发现没有死锁或者IO阻塞的情况

jstack PID
java -jar arthas.jar   --->   thread

(5)jmap查看堆内存的使用,发现堆内存的使用率已经高达88.95%

jmap -heap PID
java -jar arthas.jar   --->   dashboard

(6)此时可以大体判断出来,发生了内存泄露从而导致的内存溢出,那怎么排查呢?把日志文件上传到工具分析。

jmap -histo:live PID | more
获取到jvm.hprof文件,上传到指定的工具分析,比如heaphero.io

二十二、性能优化案例: 网站问题分析

有一个50万PV(用户日活2-3W)的资料类网站(从磁盘提取文档到内存)原服务器是64位的,4G的堆,用户反馈网站比较缓慢。因此公司决定升级,新的服务器为64位,16G的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了!

22.1 为什么原网站慢?

频繁的GC,STW时间比较长,响应时间慢!

22.2 为什么会更卡顿?

内存空间越大,FGC时间更长,停顿时间更长

22.3 怎么处理?

1)垃圾回收器: Parallel Scavenge + Parallel Old ;ParNew + CMS ; G1。
2)配置Gc参数:-XX:MaxGCPauseMillis 、-XX:ConcGCThreads根据log日志、dump文件分析,优化内存空间的比例。
3)命令:jstat jinfo jstack jmap。
在这里插入图片描述

二十三、常见问题

23.1 超大对象

代码中创建了很多大对象 , 且一直因为被引用不能被回收,这些大对象会进入老年代,导致内存一直被占用,很容易引发GC甚至是OOM

23.2 超过预期访问量

通常是上游系统请求流量飙升,常见于各类促销/秒杀活动。

23.3 过多使用Finalizer

过度使用终结器(Finalizer),对象没有立即被 GC,Finalizer线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的CPU时间较少,因此它永远也赶不上主线程的步伐,程序消耗了所有的可用资源,最后抛出OutOfMemoryError异常。

23.4 内存泄漏

大量对象引用没有释放,JVM 无法对其自动回收。

23.4.1 长生命周期的对象持有短生命周期对象的引用

例如将ArrayList设置为静态变量,则容器中的对象在程序结束之前将不能被释放。

23.4.2 连接未关闭

如数据库连接、网络连接和IO连接等,只有连接被关闭后,垃圾回收器才会回收对应的对象。

23.4.3 变量作用域不合理

例如,一个变量的定义的作用范围大于其使用范围,又没有及时地把对象设置为null。

23.4.4 内部类持有外部类

Java的非静态内部类的这种创建方式,会隐式地持有外部类的引用,而且默认情况下这个引用是强引用,因此,如果内部类的生命周期长于外部类的生命周期,程序很容易就产生内存泄漏(垃圾回收器会回收掉外部类的实例,但由于内部类持有外部类的引用,导致垃圾回收器不能正常工作)

解决方法:你可以在内部类的内部显示持有一个外部类的软引用(或弱引用),并通过构造方法的方式传递进来,在内部类的使用过程中,先判断一下外部类是否被回收。

23.4.5 Hash值改变

在集合中,如果修改了对象中的那些参与计算哈希值的字段,会导致无法从集合中单独删除当前对象,造成内存泄露

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值