JVM 面试题【中级】

690 阅读20分钟

这个章节记录一些 JVM 面试中,比较深入的点


static 静态代码块和 static 变量谁先赋值

这算是一个和不好回答的问题了,干巴巴说你不一定明白我说的是啥,还是先看看代码

public class Max {

    static {
        staticIntValue = 300;
    }
    
    public static int staticIntValue = 100;

    public final int finalIntValue = 3;

    public int intValue = 1;

    public static void main(String[] args) {
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 【问:】
    问题就是 staticIntValue 到底等于多少,
  • 【答案:】
    staticIntValue=100

我先把答案说出来,是为了大家能想想,这个问题在在好几个培训机构的公开课上都看到过,但是很难有讲到位的。这个世界上任务事情都有其根本的道理,有的看着天马行空,其实根本是我们没有这快的知识体系,所以无从下手

那么这个问题如何分析呢?上面我们写的是 java 代码,java 代码会编译成字节码,怎么赋值,赋值的先后顺序其实都是在编译时就决定好了的,我们反编译下字节码就清除了

涉及的知识点是:static 的属性申请内存空间是在类加载的验证机阶段,这个阶段会给 static 属性一个默认值,然后把 static 属性的赋值和 static 代码块结合生成一个类的初始化方法 。 中的赋值顺序和代码书写顺序一样,谁写在最后,static 的值就是哪个

先看上文代码,static 代码快在前

public class Max {

    static {
        staticIntValue = 300;
    }
    
    public static int staticIntValue = 100;
}

反编译的字节码

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: sipush        300
         3: putstatic     #9                  // Field staticIntValue:I
         6: bipush        100
         8: putstatic     #9                  // Field staticIntValue:I
        11: return
      LineNumberTable:
        line 11: 0
        line 14: 6
}

类加载准备阶段先staticIntValue开辟内存地址,赋默认值0,然后先赋值300,在赋值100

先换个顺序看看,static 代码块在后面

public class Max {

    public static int staticIntValue = 100;
    
    static {
        staticIntValue = 300;
    }
}

反编译的字节码

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        100
         2: putstatic     #9                  // Field staticIntValue:I
         5: sipush        300
         8: putstatic     #9                  // Field staticIntValue:I
        11: return
      LineNumberTable:
        line 10: 0
        line 13: 5
        line 14: 11
}

类加载准备阶段先staticIntValue开辟内存地址,赋默认值0,然后先赋值100,在赋值300

其实很简单,只要我们找对了问题对应的知识体系,一切问题其实都没有那么难


static 储存在哪

这个问题其实有陷阱啊,我们一般都会这么写:String name = new String("AA");,这道题可以问,static 变量=号左边和右边分别储存在哪,我想很多人其实说不清楚

看代码:

public class Max {

    public static byte[] values = new byte[1024*1024*60];

    public static void main(String[] args) {
        System.out.println("values: "+values);
    }
    
}

我们先看=号右边new 的部分: 方法很简单,只要把堆栈信息打印出来就行了,代码里我们搞了一个 60M 的变量出来

// 堆内存
Heap

 // 年轻代
 PSYoungGen      total 38400K, used 4663K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 14% used [0x0000000795580000,0x0000000795a0dc88,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
  to   space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
  
 // 老年代 
 ParOldGen       total 87552K, used 61440K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 70% used [0x0000000740000000,0x0000000743c00010,0x0000000745580000)
  
 // 元空间 
 Metaspace       used 3387K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

很明显,这60M内存开辟在了老年代里面,对于单个对象体积大于年轻代的一般直接放到老年代里,但是这里60M显然还够档次,我这可以是 Mac Pro 跑的代码


双亲委派机制

这里写双亲委派机制是为了再一次强调该点,面试问的太多啦

我们先回国头来再看一遍 ClassLoader 的设计:

public abstract class ClassLoader{
    
    private final ClassLoader parent;
    
    protected Class<?> loadClass(String name, boolean resolve){
        ......
    }
}

ClassLoader 对象设计有 parent 父加载器,大家看着像不像链表。链表的next指向下一个,ClassLoader parent 这里上一层级

类加载加载机制中默认不会直接由自己加载,会先用自己的父加载器 parent 去加载,父加载器加载不到再自己加载

JVM 3级类加载器,每一级都有自己能加载类的范围,类加载器一级一级提交给父加载器去加载,每一级类加载在碰到自己能加载的类时,没加载过的会去加载,加载过的会返回已经加载的class对象给下一级

看看 ClassLoader.loadClass() 方法代码:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                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.
                    c = findClass(name);
                }
            }
            return c;
    }

这个就叫做:双亲委派机制,为啥叫双亲,因为系统类加载器上面就2级类加载器

java 核心类库有访问权限限制,类加载器在发现允许加载范围之外的类加载的加载请求之后,会直接报错的。这个判断一般都是用包名来判断的,比如你自己搞了一个 String 类,包名还是 java.lang,那引导类加载器在处理这个加载请求时会直接报错,这种报错机制就叫做沙箱安全机制

沙箱安全机制有个典型例子:360沙箱隔离,比如U盘程序只在360认为隔离出来的沙箱内运行,以保护沙箱外的系统不受可能的病毒污染

双亲委派机制的目的是为了保证安全,防止核心 API 被篡改


栈内存在内存的哪块

我想这绝对是可以把你问懵逼的一道问题 o( ̄▽ ̄)d

其实这涉及到 JVM 一个机制:栈顶缓存 技术

由于操作数是存储在内存中的,因此会频繁的执行内存读/写操作,必然会影响执行速度。纬二路解决这个问题,Hotspot 虚拟机的设计者们提出了栈顶缓存技术,讲栈顶元素也就是马上要执行的栈帧(方法)缓存在cpu寄存器中,以此降低对内存的读写次数,提升执行引擎的效率

啦啦,就是这么简单,栈内存和堆内存一样不用的时候还是保存在主内存也就是屋里内存中,只有在线程抢到cpu'时间片的时候,才会把栈内存栈顶的栈帧加载进cpu缓存中,至于是:L1->L2->L3 就不得而知了,估计应该是L1的面更大。完事根据java锁升级机制还会对这块有优化,比如很快会再次运行的线程的栈顶栈帧可能会提前加载进cpu缓存,这次估计就是L3了


TLAB

TLAB - 线程私有缓存

堆内存是线程间共享的,若是多个线程操作同一个内存地址也是会产生并发问题的,JVM对于这个问题就是加锁。针对这个场景的就是对象创建了,多个线程同一时刻都要在堆内存分配空间来创建对象,这就是资源冲突,而且对象创建是非常频繁的,要是每次都加锁那性能还能看吗  ̄△ ̄

所以TLAB产生了,解决问题的最好办法就是消灭问题本身,不就是资源冲突嘛 ( ̄_, ̄ ) 那我们每个线程在堆内存中搞个私有空间不就行了,这一小块内存空间只给一个线程使用不就没有资源冲突了嘛,不就不用加锁了嘛,这种策略叫:快速分配策略

当然这个TLAB的空间肯定不大,默认大小是每个线程的TLAB占 Eden 的1%,所以线程多了堆内存也扛不住,我估计JVM肯定也有优化的,不是这么简单的

  • -XX:UseTLAB: 开启TLAB,默认是打开的
  • -XX:+TLABSize
  • -XX:TLABWasteTargetPercent=1 TLAB 占 Eden 的比例
  • -XX:TLABRefillWasteFraction
  • -XX:+PrintTLAB

一旦 TLAB 失败,JVM就会加锁保证数据操作的原子性,直接在 Eden 中分配内存

更详细的部分请看:jvm 优化篇-(5)-线程局部缓存TLAB 指针碰撞、Eden区分配


栈上分析、逃逸分析

有面试管问:对象是都在堆上分配吗? 这个考的你知不知道栈上逃逸这个点

随着JIT编译器技术的发展,栈上分配、标量替换这些优化技术会动摇所有对象都分配到堆内存上这个观点

经过逃逸技术分析后,如果一个对象没有逃逸出方法的话,那么就可能被优化到直接在栈上分配内存,而不是去堆里开辟内存区域了

阿里巴巴自己的 JVM TaobaoVM 创新出了一个技术 GCIH,生命周期较长的对象可以从heap中移至heap外,从而不必被GC管理,达到降低GC频率,调高GC效率的目的

什么是栈上分配呢,简单来说,方法内创建的对象声明周期要是没有超过该方法的话,new 对象申请内存可以直接在栈帧所在内存上进行,而不用再跑去堆内存上了,堆内存还要有TLAB和加锁

前面分析过,IBM 认为80%的对象都是朝生夕死的,就是说在一个对象在方法内new,完事随着方法执行完成而销毁。如果对象都是在堆内存上开辟的话,第一会进行TLAB优化,TLAB放不下了会在堆内存中加锁,加CAS操作去开辟内存空间,大量的对象的创建销毁会频繁引起YGC

若是这样朝生夕死的对象在栈帧里开辟空间,根据栈内存栈顶缓存机制,将要执行的栈帧会加载到CPU高速缓存中,然后方法结束后对象随着栈帧数据一起销毁回收,优点是:

  1. cpu高速缓存速度那是真的快,比内存快100倍
  2. 减少cpu和内存的通信,也可以节省一些时间,内存到CPU毕竟有速度限制,在cpu时钟周期角度来说是很慢的
  3. 大大减少了堆内存压力,可以有效减少YGC的频率,这是代码优化很重要的一点

注意栈上分配,优化的是方法中new的对象而不是其他

典型的栈上分配例子:

public void name1(){
    String name = new String("AA");
    int length = name.length();
}

优化的就是name了,有了栈上分配技术,name就不用开辟在堆空间了

JDK7开始,默认就开启了逃逸分析

  • -XX:+DoEscapeAnalysis: 开启逃逸分析
  • -XX:PrintEscapeAnalysis: 打印逃逸分析结果

逃逸分析是在编译期就确定的了,虽然我们人自己可以确定最终对象是不是逃逸了,但对于编译器来说就不能确定了,只要不是百分百确定,那么方法里new的对象就无法使用栈上分配技术

所以分析逃逸成不成,关键就是看方法内new的对象是不是被外部引用

下面栈上逃逸不能起作用的例子,知己知彼百战不殆

public class Max {

    public Dog dog ;

    /**
     * 逃逸失败,方法返回的对象有可能是全局变量
     * 但是对于编译器来说不是百分之百可以确定对象生命周期只在方法内
     * 所以即便最后真的返回的是new的Dog,这个new的对象也是在堆内存上分配的
     * @return
     */
    public Dog dog1(){
        return dog == null ? new Dog() : dog;
    }
    
    /**
    * 逃逸失败,new 的对象引用直接被交给全局变量了
    */
    public void getDog(){
        this.dog = new Dog();
    }
    
    public Dog getVlue(){
        return new Dog();
    }

    /**
     * 逃逸失败,dog 的生命周期虽然没有出dog2()这个方法
     * 但是对于getVlue()方法中new的对象来说,这个对象是跨栈帧的了
     * 栈帧之间不能相互访问,执行完一个栈帧,帧帧数据就销毁了
     * 所以getVlue()方法里面new的对象必须在堆中开辟
     * 要不对象是new出来了,但是随着栈帧一起销毁了,还怎么返回给别的方法使用啊
     */
    public void dog2(){
        Dog dog = getVlue();
    }
}

最后结论:开发中能使用局部变量的,就不要在方法外面定义了

神转折来啦 (/// ̄皿 ̄)○~ 这是《深入理解JVM虚拟机里的原话》

逃逸分析的技术99年就出现了,一直到JDK1.6 Hotspot 才开始支持初步的逃逸分析,即便到现在这项技术仍未成熟,还有很大的改进余地。不成熟的原因是逃逸分析的计算成本非常高,甚至不能保证带来的性能优势会高于计算成本,在实际应用中,尤其是大型应用中反而发现逃逸发分析可能出现不稳定的状态。直到JDK7时才默认开启这项技术,服务模式的java程序才支持

JVM 角度看代码优化中例子效果明显,更多原因是因为下面会说的标量替换,没看到内存快照嘛,即便开启逃逸分析之后,Dog对象在堆内存中还是有非常多的对象存在,这和理论差距还是满大的


字符串拼接面试题

面试官们都喜欢问字符串的面试题,笔试题更是不用说了,什么时候也少不了 String 的身影,尤其是字符串拼接的问题

字符串拼接有4种结果:

  • 以字面量声明的话,会储存到字符串常量池
  • 常量与常量的拼接结果存在字符串常量池,原理是编译期优化
  • 只要有一个是引用变量,结果就存在堆内存中,原理是 StringBuilder
  • 如果拼接结果使用intern(),那么结果会保存到字符串常量池中

干说无意,下面用代理说话

1. 字面量

String name = "abc";

"xx"用双引号写的就是字面量,这样的字符串会优化保存到常量池里面,说一下,是因为一说字面量好多人就不知道是什么东东了 ~( ̄▽ ̄~)(~ ̄▽ ̄)~

只要碰到"xx"这样用双引号声明的字符串,即便是new String("abc")这样的,在编译期统一会在常量池里创建对应的字符串对象

字符串常量池里存的是 String 对象的引用,并不是对象本身啊,这点要注意,很重要的,讲 String.intern() 方法时很重要的,要不你捋不清楚

以这行代码为例说下此时的内存结构

String name = new String("abc");

一共2个对象,new 关键字肯定会在堆内存生成一个String对象的,但是这不是碰到"abc"了嘛,JVM在编译时会有优化,会在字符串常量池中把这个字符串对象创建出来

2. 字面量拼接字符串

String name1 = "a"+"b"+"c"
String name2 = "abc";

System.out.println(name1==name2); // true
System.out.println(name1.equals(name2)); // true

"a"+"b"+"c"像这样完全用字面量去做拼接的字符串,是储存在常量池中的,所以name1和name2的引用地址是一样的,他们都指向字符串常量池种的同一个位置

这种拼接在编译时会直接优化成最终结果,在编译时就是abc

看看反编译的字节码:

 0 ldc #14 <abc>
 2 astore_1
 3 ldc #14 <abc>
 5 astore_2
 6 getstatic #3 <java/lang/System.out>
 9 aload_1
10 aload_2
11 if_acmpne 18 (+7)
14 iconst_1
15 goto 19 (+4)
18 iconst_0
19 invokevirtual #15 <java/io/PrintStream.println>
22 getstatic #3 <java/lang/System.out>
25 aload_1
26 aload_2
27 invokevirtual #16 <java/lang/String.equals>
30 invokevirtual #15 <java/io/PrintStream.println>
33 return

看到2个<abc>了吧,加<>标识常量池

3. 引用拼接字符串

String name1 = "a";
String name2 = "b";
String name3 = "ab";
String name4 = name1+name2;
String name5 = name1+"b";

System.out.println(name3==name4); // false
System.out.println(name3==name5); // false

字符串拼接只要其中有一个是引用类型的,那么拼接结果就不储存在常量池种了,而是个存储在堆内存中,完全当做一个对象去处理的

还是去看看反编译的字节码

 0 ldc #16 <a>
 2 astore_1
 3 ldc #17 <b>
 5 astore_2
 6 ldc #18 <ab>
 8 astore_3
 9 new #4 <java/lang/StringBuilder>
12 dup
13 invokespecial #5 <java/lang/StringBuilder.<init>>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append>
24 invokevirtual #9 <java/lang/StringBuilder.toString>
27 astore 4
29 new #4 <java/lang/StringBuilder>
32 dup
33 invokespecial #5 <java/lang/StringBuilder.<init>>
36 aload_1
37 invokevirtual #7 <java/lang/StringBuilder.append>
40 ldc #17 <b>
42 invokevirtual #7 <java/lang/StringBuilder.append>
45 invokevirtual #9 <java/lang/StringBuilder.toString>
48 astore 5
......
84 return

看到了吧,字节码里 new 了2个 StringBuilder 出来,最后结果是 StringBuilder.toString 创新了一个新的String对象出来的

拼接时使用的是 StringBuilder.appen 方法,看源码,是在字符串数组后面添加数据,而没有在中间过程创建String对象出来

    @Override
    public AbstractStringBuilder append(char c) {
        ensureCapacityInternal(count + 1);
        value[count++] = c;
        return this;
    }

这点很重要啊,面试妥妥的虐你 (。・∀・)ノ

4. 最后再看一下

我方一下常出现的字符串拼接题:

String name1 = "abc";
String name2 = "edf";
String name3 = "abcedf";
String name4 = "abc"+"edf";
String name5 = name1+"edf";
String name6 = "abc"+name2;
String name7 = name1+name2;
String name8 = name6.intern();

System.out.println(name3==name4); // true
System.out.println(name3==name5); // false
System.out.println(name3==name6); // false
System.out.println(name3==name7); // false
System.out.println(name5==name6); // false
System.out.println(name5==name7); // false
System.out.println(name6==name7); // false
System.out.println(name3==name8); // true

intern() 会把堆内存种字符串对象的字符存储到字符串常量池中,常量池若是有这个字符串了,会返回该字符串在常量池中的地址


经典面试题:有几个字符串对象

String name = new String("abc");
String name2 = new String("AA") + new String("BB");

再重复一次:String name = new String("abc");时内存、对象分配情况

一共2个对象,new 关键字肯定会在堆内存生成一个String对象的,但是这不是碰到"abc"了嘛,JVM在编译时会有优化,会在字符串常量池中把这个字符串对象创建出来

问:上面这2句话分别有几个对象创建出来

这个问题真没几个人说的对的

这个问题的关键在2个:字面量字节码,这2个知道了那就不是问题了 ✧(≖ ◡ ≖✿)

字面量: "AA" 只要使用双引号声明的字符串,不管是在=右边直接写的,还是在 new String 里面写的,在编译时都会在字符串常量池中保存一份,但是并不是说 new String 的方式在堆内存就不会new对象出来了,对象还是有的,堆内存一份,字符串字节码一份

1. String name = new String("abc");

  • 对象1: new String 堆内存中的对象
  • 对象2: ldc 字符串常量池中的常量对象

一切看字节码,我们怎么猜都是没用的,一切在编译时就确定的了

 0 new #13 <java/lang/String>
 3 dup
 4 ldc #6 <abc>
 6 invokespecial #14 <java/lang/String.<init>>
 9 astore_1
10 return

0行、4行 2个对象,ldc 是开辟常量池的指令

2. String name2 = new String("AA") + new String("BB");

  • 对象1: new StringBuilder 对象
  • 对象2: new String 对象
  • 对象3: ldc 字符串常量池
  • 对象4: new String 对象
  • 对象5: ldc 字符串常量池
  • 对象6: StringBuilder.toString() -> 返回一个 String 对象
 0 new #4 <java/lang/StringBuilder>
 3 dup
 4 invokespecial #5 <java/lang/StringBuilder.<init>>
 7 new #13 <java/lang/String>
10 dup
11 ldc #14 <AA>
13 invokespecial #15 <java/lang/String.<init>>
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 new #13 <java/lang/String>
22 dup
23 ldc #16 <BB>
25 invokespecial #15 <java/lang/String.<init>>
28 invokevirtual #7 <java/lang/StringBuilder.append>
31 invokevirtual #8 <java/lang/StringBuilder.toString>
34 astore_1
35 return

0行、7行、11行、19行、23行、31行 一共6个对象。有人会问 StringBuilder.toString 怎么就一个对象呢,不应该是字符串常量池还有一个对象嘛~ 因为 StringBuilder.toString 返回的 String 对象没用 "AA" 双引号声明字符串,所以在编译时是不会有优化的

还又人问我们学这个有实际意义嘛,写代码时谁管你有几个对象 (@ ̄ー ̄@)

但是你连你写的代码会产生几个对象都不知道,那你怎么会知道堆内存是什么状况,怎么会知道什么是好代码,什么是坏代码,GC 爆了你怎么处理,牵一发而动全身,编程知识基本都是关联的,这里你不清楚,好多问题你无法处理,只剩下懵逼2字


String.intern() 面试题

intern 方法会把String对象中的字符串同步到字符串常量池中去,并返回该字符串在常量池中的地址。若此时字符串常量池中没有该字符串对象,那么就把调用 intern 方法的字符串对象的地址写到字符串常量池中

面试题1:

String name1 = new String("abc");
name1.intern();

String name2 = "abc";
System.out.println(name1 == name2);//false
  • name1 指向的是在堆内存 new 出来的对象的地址
  • name2 指向的是字符串常量池中这个字符串的地址,早在new对象时,该字符串就已经在常量池中创建出来了,原因就是有字面量"abc"

面试题2:

String name1 = new String("ab") + new String("c");
name1.intern();

String name2 = "abc";
System.out.println(name1 == name2);//true
  • name1 指向的是在堆内存 new 出来的对象的地址,因为没有字面量声明,此时常量池中时没有 abc 的
  • name1.intern 会把 name1 的地址提交给字符串常量池
  • name2 指向的是字符串常量池中这个字符串的地址,该地址因为是 name1 通过 intern 方法提交的,所以 name2 拿到的常量池中字符串的地址其实就是 name1

面试题3:

String name1 = new String("ab") + new String("c");
String name2 = "abc";
        
name1.intern();
System.out.println(name1 == name2);//false
  • 因为在 name1.intern 之前,name2 就通过字面量在常量池中创建出字符串对象了,所以 name1 就没法把自己提交给常量池了

面试题4:

String name1 = new String("ab") + new String("c");
String name2 = "abc";

String name3 = name1.intern();
System.out.println(name3 == name2);//true

这个就不用说了把,intern 方法会返回常量池中该字符串的地址

面试题5:

String name1 = new String("ab") + new String("c");
String name2 = name1.intern();

System.out.println(name1 == "abc");//false
System.out.println(name2 == "abc");//true