面试话痨(四)常量在哪里呀,常量在哪里

  面试话痨系列是从技术广度的角度去回答面试官提的问题,适合萌新观看!

  常量在哪里呀,常量在哪里,常量在那小朋友的眼睛里


   

 

一、从一道经常问的字符串题说起

  面试官:已知String s1 = "ab",String s2 = "a" + "b",String s3 = new String("ab"),求s1、s2、s3的相等情况。

  进阶版的还会将intern(),final String s1 = "ab" 这些情况加进来。

  相等的判定分为两种,equals和==。equals我们知道都是相等的,面试话痨(二)中已经详细描述过了,这里我们重点来研究下“==”的情况。

 

  “==”考验的是我们对JVM结构和编译运行过程知识的掌握

二、简单说下JVM内存模型

  JVM内存模型这里主要说下它存数据的地方,这个地方被称作运行时数据区,主要分为三个部分:堆,栈,程序计数器。这里没有把方法区算作第四个部分,因为方法区只是一个概念。打个比方,JVM是一个房间,堆,栈,程序计数器就是鞋柜,沙发和床,那么方法区就是 吃饭的地方。吃饭的地方可以是餐桌,阳台甚至厕所。

  不同版本的JDK,方法区实际指代的区域都不一样。1.6方法区是用永久代实现的,1.7是用永久代和堆,1.8是元空间加堆。方法区比较复杂,我们先把堆,栈,程序计数器熟悉了。

  我们先来通过一段代码,熟悉堆,栈,程序计数器。

 

public void test() {
HashMap map = new HashMap(); String s1
= new String("123"); }

  这段代码是如何被运行的?

  首先得有个线程来执行是吧,不论是main的主线程,还是通过线程池开启的其他线程,线程被创建时,都会建立一个线程私有的栈和程序计数器。线程总会按照顺序执行一个或者多个方法,每个方法在被执行时,都会在线程私有的栈中新建一个格子,这个格子被称作

 

  我们都知道栈是一种数据结构,那为什么这里要用栈,而不是用队列呢?因为栈的特点是先进后出,这个跟我们方法调用规则一致,当方法一调用了方法二,需要方法二执行完成才能返回来执行方法一,即先进后出。

  栈还分为本地方法栈和虚拟机栈。本地方法栈执行一些计算机底层C提供的方法,他们都是用native关键字修饰的,比如Object内的getClass方法。虚拟机栈执行java方法。

 

  回归正题,当某个线程调用了test()方法时,便会在自己的私有栈中新增一帧。然后逐行执行编译后的代码,并且会用程序计数器记录代码已经执行到哪一行了。

  为什么需要程序计数器呢?在面试话痨(三)中,我们讲过,CPU因为运算能力太强,所以都是通过时间片轮转制度同时做很多件事情。如果一个线程的时间片用完了,那么它就会被强行停止,为了保证下一次唤醒它时我们能继续执行,就需要准确的记住线程状态。栈只能记住线程被执行到哪一个方法(帧)了,不能记住执行到方法的哪一行了。

  所以需要一个程序计数器,方便线程被再次唤醒时,准确的恢复线程的执行状态。

 

  再说点题外话,发散一下。在线程被强行停止时,会保存线程的最新状态,尔后在线程被唤醒时,重新加载线程的最新状态,这个过程,被称为上下文切换。程序计数器就是为了上下文切换而存在的。它的存在增加了空间复杂度,但是换来了CPU的多线程运行。上下文切换主要有三种,线程间上下文切换,进程间上下文切换,用户态内核态上下文切换。

  1. 线程间上下文切换。若两个线程属于不同的进程,那么此次线程间切换就是进程间切换;若是进程内部的两个线程切换,那么它的速度会快很多。因为线程间共享的区域是不用缓存再恢复的,只用缓存线程私有的栈、程序计数器信息。

  2. 进程间上下文切换需要保存大量的信息,包括用户态下的虚拟内存、栈、堆,还包括内核态下的堆、栈、寄存器。一次切换往往需要浪费掉几十纳秒到几微妙的时间。

  3. 内核态用户态上下文切换。内核态拥有更高的管理权限,相当于我们平常用cmd时,右键选择了以管理员身份运行。最简单的,读取文件就需要内核态的权限去读取。所以当你在代码中写下 new FileInputStream(new File("C:/aa.txt")); 时,就存在两次上下文切换,一次用户态切换成内核态,读取到文件信息,一次内核态切换回用户态,将文件信息换成用户态可以直接操作的对象。后续如果需要对外传输文件,也需要用到内核态的权限去打开Socket通道。所以就有了一个有关文件传输的优化:零拷贝技术。直接一次下发文件的拷贝,传输命令,CPU会将数据从硬盘中放到内存,将内存地址发送到Socket缓存区,再调用Socket发送数据,将6次上下文切换优化成2次。

  在前端中,也有上下文切换的概念,前端中的上下文切换考察的是从一个方法进入另外一个方法后,全局变量、局部变量的预加载,以及this指针重定向到何处,和这里的不一样。

 

  回归正题。通过上面的介绍,我们已经知道了线程在执行test()方法时,栈、帧、程序计数器是怎么配合的。并且通过了解先进先出、上下文切换做到了知其然且知其所以然。如果没有记清楚,建议再看一遍。因为后面还有更复杂的东西需要掌握。

  我们已经知道了test()方法被加载时的准备工作,那在每一行的执行过程中,JVM是如何工作的?

  比如 HashMap map = new HashMap(); ,这句到底干了啥?

  很简单,第一步,在堆中中开辟一个空间,用于存放new HashMap()。第二步,在test()对应的帧中新建一个局部map指针,指向堆中的new HashMap()地址。

 

  第一步,new HashMap()在堆中开辟了一个空间。堆其实还分为很多个部分。最老派的分法是,新生代,老年代,永久代,新生代又分为又分为一个伊甸园区和两个幸存区。伊甸园就是亚当和夏娃偷吃苹果的那个伊甸园,寓意着万物之始,所以一般来说,新建的对象都是在这个伊甸园区的。当然如果对象过大,大到伊甸园区的剩余可用空间装不下,它会直接建到老年代区,如果老年代也不够,那就会触发垃圾回收。

  第二步,我们都知道,这个map是个局部变量,局部变量只在方法内有效,为什么局部变量只在方法内有效?就是因为它是被建在帧中的,与帧同生共死。一个帧就是一个方法,当方法被执行完后,帧就需要从线程栈中出栈,相应地,帧中的map指针也被丢弃,new HashMap()在堆中创建的空间也会被标记为不可达(没有存活的指针指向该对象),不可达的对象会在下次GC时被JVM回收(回收前会调用finalize方法,具体逻辑面试话痨二中有介绍)。

  总的来说,栈,堆,程序计数器管的是方法执行过程中的事,垃圾回收管的是方法执行完成之后的事,我们后面细说,剩下的方法执行之前的准备工作,就归方法区管了

  方法区存放着类编译后的字节码,常量,静态变量等信息(注意普通的全局变量,会在类对象被创建时,一起创建在堆中,这也是为什么静态变量、常量可以用类直接访问,而普通的全局变量需要对象创建出来以后才能访问的原因)。

  对于常量,我们这里需要特别说明。方法区中有个专门的运行时常量池来存放常量,因为常量有不可修改的特性,所以如果常量值相等的引用,可以优化成一个内存地址。JVM中不同地方的"ab"和"ab"会被指向同一个地址。

  另外Byte,Short,Integer,Long,Character这五个基础类的包装类的-128至127的值也会直接建立常量池,如 Integer i1 = 12; Integer i2 = 12 中,i1和i2就同时指向了常量池中的地址,所以i1 == i2 的结果是true,而-128至127以外的数,指向的就不是一个地址了。

  方法区jdk1.6中是通过永久代实现的。用永久代的原因是因为懒,想跟堆用一套GC算法。但是后续发现,方法区中的静态变量、常量这种数据对象,和普通对象一样适用于堆的GC算法,但是对于类编译后的方法啊,关键字啊这些东西,不适应于GC算法。所以也就有了JDK1.7、JDK1.8中的逐渐将运行时常量池,静态变量移入堆中,将其他的信息放入独立的元空间的操作。元空间就是外部的直接内存,堆是JVM的虚拟内存。

  网上一般说的移到元空间的原因有两个,一是元空间使用物理内存,理论上不会再有内存溢出的问题(内存占用过高时,cpu会通过强制失效机制将一部分数据放入磁盘,要用该部分数据时再从磁盘加载回内存。所以理论上不会再有内存溢出,只有可能CPU100%),二是使用直接内存,读取和写入的速度都会更快。但是我个人觉得,还是因为GC算法闹不合,导致了他们的分家。

  关于常量池还有一些容易记混的知识,这里一并说下。常量池分为class类常量池和运行时常量池。class类常量池是在编译后产生的,是放在class文件中的,是在硬盘中的数据。而运行时常量池是class类常量池被加载到JVM后的数据,是放在内存(虚拟内存)中的。另外还有个字符串常量池,在我看来,字符串常量池只是class类常量池或者运行时常量池中的一个小类,它能被单独提出来说,是因为在JDK优化方法区的过程时,在JDK1.7中优先将字符串常量池从运行时常量池中剥离了出来,先转移到了堆中,尔后,1.8中将剩余的整个运行时常量池都转入了堆,那么也就没有了单独的字符串常量池。所以我认为,字符串常量池应该只是一个JDK1.7中的历史产物,它之所以还会被提起,就是因为JVM对于字符串常量独特的优化,这个优化也是这道面试题存在的根本原因。


  以上就是关于JVM内存模型的各个部分的介绍。下面我们先试着用这部分知识,解决面试题中的一部分问题吧。

 

1     public static void main(String[] args) {
2         String s1 = "ab";
3         String s2 = "ab";
4         String s3 = new String("ab");
5         String s4 = new String("ab");
6         System.out.println(s1 == s2);
7         System.out.println(s1 == s3);
8         System.out.println(s3 == s4);
9     }

 

  好好想一想,编译后的class文件是从哪里被读取到了哪里,线程是通过哪两种结构来记录程序执行步骤的,为啥是用着两种结构实现?执行第二行时,是在哪里创建的对象空间,又是在哪里保存了指向该对象的指针?执行第三、四、五行时,是新创建空间还是用老的?最终的判等结果是什么?

  为什么方法内创建的变量是局部变量?为什么普通的全局变量必须通过类的对象去访问,而类中的静态变量和常量可以直接通过类名访问?

  相同内容的字符串常量会指向同一个地址,还有哪些数据会有这种情况?

  方法区的实现是如何改变的?为什么会这么改变?

  最后,JVM的运行时数据区和运行时常量池的区别什么?运行时数据区由哪些部分组成,每个部分的作用是什么?

  如果能回答出以上的问题,那么继续往下看吧,如果回答不出来,你可能有点晕了,建议休息一下再看一遍。

 

三、简单说下JVM编译和装载

 

  下面代码的结果是什么?

 

    public static void main(String[] args) {
        String s1 = "a" + "b";
        String s2 = "ab";
        System.out.println(s1 == s2);
    }

  这两个语句是否相等,主要是要明白JVM的编译装载运行过程,主要涉及到编译和装载两步

  将程序员能读懂的高级编程语言,转换成计算机能读懂的二进制语言,这个过程就是编译

  广义的编译的步骤是:词法分析,语法分析,语义分析,中间代码生成及代码优化,二进制代码生成。当然因为Java是转给JVM看的,所以Java中的编译,最终生成的不是二进制文件,而是class文件编译不是一个简单的事,不信你试着去写一段代码:输入一段字符串,该字符串是一段数学运算,包含加、减、乘、除、正号、负号、小括号,求出该运算的最终结果)。

 

  编译的前三步很好记,就跟我们读英语一样,先判断每个单词拼写对不对(词法分析),再判断单词的时态对不对(语法分析),再判断整句的意思是否矛盾(语义分析)。

  至于中间代码生成及代码优化,就是编译器对代码的一些补充和调整。通过补充和调整让代码更规范、性能更好。比如 int daySecond = 24 * 60 * 60; ,这个编译后就是 int daySecond = 86400; 。因为无论运行时的前后代码变量是什么,daySecond的值都是86400,所以编译时会将代码直接计算成86400,提升运行时的效率。

  第二步是装载,装载是通过双亲委派机制,将类的编译后信息放入方法区,然后在堆中建立指向。方法区中放的不止有类的编译信息,只是在装载这一步,只装载了类的编译信息。

  比如这个“a” + "b",“a”和“b”都是已知的不会更改的常量,不论“a” + "b"的前后有怎样的代码,它的结果都是“ab”,对于这种代码,编译时肯定就会被优化成“ab”。如图:

   左边为编译之后的class,“a” + "b"已被合并。

  通过第一步编译,我们知道“a” + "b"已经被优化成了"ab",但这还并不能说明String s1 = "ab"与String s2 = "a" + "b"是"=="的,我们还得看第二步:装载。

  装载就是通过包名+类名获取到指定类的字节流,将其放入方法区。方法区中包括类的基本信息,类编译后的代码,常量,变量。但是在装载这一步中,只会先将类的基本信息,类编译后的代码,常量放入方法区。并在堆中新建一个该类的对象,指向了方法区中的类信息。

  装载这一步时,就会将常量放到方法区中的运行时常量池。这里就用到了上面说过的字符串常量池,若字符串常量池中已存在相同的字符串,则不会生成新的字符串。因为常量是不可更改的,所以不用担心多个指针引用同一个地址时,造成的数据水波。

  因为在编译这一步,"a" + "b"被优化成了"ab",又因为在装载这一步,又会将内容一致的字符串指向同一个地址,所以s1等于s2。

  同理,大家应该能还快看出以下代码的结果

    public static void main(String[] args) {
        String s1 = "ab";
        
        String s2 = "a";
        String s3 = s2 + "b";
        System.out.println(s1 == s3);

        final String s4 = "a";
        String s5 = s4 + "b";
        System.out.println(s1 == s5);
    }

  希望大家能通过编译和加载的原理明白为什么"a" + "b"等于"ab",也能通过"a" + "b"等于"ab"记住编译和加载的原理。

四、简单说下剩下的JVM链接和初始化

  链接分为了三步

  ① 验证 : 校验类的格式,数据,符号的正确性。验证时的异常也属于编译时异常,与编译阶段的主要区别是,编译阶段是在某个文件内部验证语法语义的正确性,链接中的校验是通过类之间的调用关系,链起来判断代码的正确性。

  ② 准备:  预加载类的静态变量,并赋初始值0、null

  ③ 解析: 将类中的符号引用转换成直接引用,如类A中引用了类B,那么在编译时我们并不能确认类B的实际的地址,所以只能先用符号引用占位,等到解析时再转换成直接引用

  初始化主要是将链接的准备阶段中的静态变量,替换成实际的值。以及执行静态代码块,执行的顺序是优先父类的静态代码执行。

  使用就是利用JVM中的栈、程序计数器、堆,去执行实际的代码逻辑,操作对应数据,获取代码结果。

五、总结及发散

  JVM的相关知识,其实可以通过三个阶段来记,使用前,使用中,使用后。

  使用前需要做好准备,包括校验程序员写的代码,再转换成JVM能读懂的代码,再根据需要加载当前需要的一部分代码,并把一部分可以提前确定的数据初始化。

  使用中则根据使用前准备好的代码和数据,一行一行的执行代码。通过栈记录线程,通过栈记录方法,通过程序计数器记录执行到哪一行,通过堆记录代码执行过程中所需的数据。

  使用后则需要有专门的清洁工收拾残余垃圾,也就是GC。具体的看后续专门介绍(面试话痨N)。

  希望大家能够通过String的几道面试题,记牢JVM使用前,使用中的过程及原理。

 

posted @ 2020-11-23 21:37  有营养的yyl  阅读(489)  评论(0编辑  收藏  举报