Sunday, November 7, 2021

SegmentFault 最新的文章

SegmentFault 最新的文章


超过1W字深度剖析JVM常量池(全网最详细最有深度)

Posted: 07 Nov 2021 01:19 AM PST

面试题:String a = "ab"; String b = "a" + "b"; a == b 是否相等

面试考察点

考察目的: 考察对JVM基础知识的理解,涉及到常量池、JVM运行时数据区等。

考察范围: 工作2到5年。

背景知识

要回答这个问题,需要搞明白两个最基本的问题

  1. String a="ab",在JVM中发生了什么?
  2. String b="a"+"b",底层是如何实现?

JVM的运行时数据

首先,我们一起来复习一下JVM的运行时数据区。

为了让大家有一个全局的视角,我从类加载,到JVM运行时数据区的整体结构画出来,如下图所示。

对于每一个区域的作用,在我之前的面试系列文章中有详细说明,这里就不做复述了。

image-20211106144143909

在上图中,我们需要重点关注几个类容:

  1. 字符串常量池
  2. 封装类常量池
  3. 运行时常量池
  4. JIT编译器

这些内容都和本次面试题有非常大的关联关系,这里对于常量池部分的内容,先保留一个疑问,先跟随我来学习一下JVM中的常量池。

JVM中都有哪些常量池

大家经常会听到各种常量池,但是又不知道这些常量池到底存储在哪里,因此会有很多的疑问:JVM中到底有哪些常量池?

JVM中的常量池可以分成以下几类:

  1. Class文件常量池
  2. 全局字符串常量池
  3. 运行时常量池

Class文件常量池

每个Class文件的字节码中都有一个常量池,里面主要存放编译器生成的各种字面量和符号引用。为了更直观的理解,我们编写下面这个程序。

public class StringExample {     private int value = 1;     public final static int fs=101;      public static void main(String[] args) {         String a="ab";         String b="a"+"b";         String c=a+b;     } }

上述程序编译后,通过javap -v StringExample.class查看该类的字节码文件,截取部分内容如下。

Constant pool:    #1 = Methodref          #9.#32         // java/lang/Object."<init>":()V    #2 = Fieldref           #8.#33         // org/example/cl07/StringExample.value:I    #3 = String             #34            // ab    #4 = Class              #35            // java/lang/StringBuilder    #5 = Methodref          #4.#32         // java/lang/StringBuilder."<init>":()V    #6 = Methodref          #4.#36         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StrvalueingBuilder;    #7 = Methodref          #4.#37         // java/lang/StringBuilder.toString:()Ljava/lang/String;    #8 = Class              #38            // org/example/cl07/StringExample    #9 = Class              #39            // java/lang/Object   #10 = Utf8               value   #11 = Utf8               I   #12 = Utf8               fs   #13 = Utf8               ConstantValue   #14 = Integer            101   #15 = Utf8               <init>   #16 = Utf8               ()V   #17 = Utf8               Code   #18 = Utf8               LineNumberTable   #19 = Utf8               LocalVariableTable   #20 = Utf8               this   #21 = Utf8               Lorg/example/cl07/StringExample;   #22 = Utf8               main   #23 = Utf8               ([Ljava/lang/String;)V   #24 = Utf8               args   #25 = Utf8               [Ljava/lang/String;   #26 = Utf8               a   #27 = Utf8               Ljava/lang/String;   #28 = Utf8               b   #29 = Utf8               c   #30 = Utf8               SourceFile   #31 = Utf8               StringExample.java   #32 = NameAndType        #15:#16        // "<init>":()V   #33 = NameAndType        #10:#11        // value:I   #34 = Utf8               ab   #35 = Utf8               java/lang/StringBuilder   #36 = NameAndType        #40:#41        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;   #37 = NameAndType        #42:#43        // toString:()Ljava/lang/String;   #38 = Utf8               org/example/cl07/StringExample   #39 = Utf8               java/lang/Object   #40 = Utf8               append   #41 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;   #42 = Utf8               toString   #43 = Utf8               ()Ljava/lang/String; 

我们关注一下Constant pool描述的部分,表示Class文件的常量池。在该常量池中主要存放两类常量。

  1. 字面量。
  2. 符号引用。

字面量

  • 字面量,给基本类型变量赋值的方式就叫做字面量或者字面值。 比如:String a="b" ,这里"b"就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

    在上述代码中,字面量常量的字节码为:

    #3 = String             #34            // ab #26 = Utf8               a #34 = Utf8               ab
  • final修饰的成员变量、静态变量、实例变量、局部变量,比如:

      #11 = Utf8               I   #12 = Utf8               fs   #13 = Utf8               ConstantValue   #14 = Integer            101

从上面的字节码来看,字面量和final修饰的属性是保存在常量池中,这些存在于常量池的字面量,指得是数据的值,比如ab101

对于基本数据类型,比如private int value=1,在常量池中只保留了他的字段描述符(I)字段名称(value),它的字面量不会存在与常量池。

  #10 = Utf8               value   #11 = Utf8               I

另外,对于String c=a+b;c这个属性的值也没有保存到常量池,因为在编译期间,ab的值时不确定的。

#29 = Utf8               c #35 = Utf8               java/lang/StringBuilder #36 = NameAndType        #40:#41        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder; #37 = NameAndType        #42:#43        // toString:()Ljava/lang/String; #39 = Utf8               java/lang/Object #40 = Utf8               append #41 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;

如果,我们把代码修改成下面这种形式

public static void main(String[] args) {   final String a="ab";   final String b="a"+"b";   String c=a+b; }

重新生成字节码之后,可以看到字节码发生了变化,c这个属性的值abab也保存到了常量池中。

#26 = Utf8               c #27 = Utf8               SourceFile #28 = Utf8               StringExample.java #29 = NameAndType        #12:#13        // "<init>":()V #30 = NameAndType        #7:#8          // value:I #31 = Utf8               ab #32 = Utf8               abab

符号引用

符号引用主要设涉及编译原理方面的概念,包括下面三类常量:

  1. 类和接口的全限定名(Full Qualified Name),也就是Ljava/lang/String;,主要用于在运行时解析得到类的直接引用。

      #23 = Utf8               ([Ljava/lang/String;)V   #25 = Utf8               [Ljava/lang/String;   #27 = Utf8               Ljava/lang/String;
  2. 字段的名称和描述符(Descriptor),字段也就是类或者接口中声明的变量,包括类级别变量(static)实例级的变量

    #1 = Methodref          #9.#32         // java/lang/Object."<init>":()V #2 = Fieldref           #8.#33         // org/example/cl07/StringExample.value:I #3 = String             #34            // ab #4 = Class              #35            // java/lang/StringBuilder #5 = Methodref          #4.#32         // java/lang/StringBuilder."<init>":()V #6 = Methodref          #4.#36         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StrvalueingBuilder; #7 = Methodref          #4.#37         // java/lang/StringBuilder.toString:()Ljava/lang/String; #8 = Class              #38            // org/example/cl07/StringExample    #24 = Utf8               args #26 = Utf8               a #28 = Utf8               b #29 = Utf8               c
  3. 方法的名称和描述符,方法的描述类似于JNI动态注册时的"方法签名",也就是参数类型+返回值类型,比如下面的这种字节码,表示main方法和String返回类型。

      #19 = Utf8               main   #20 = Utf8               ([Ljava/lang/String;)V
小结:在Class文件中,存在着一些不会发生变化的东西,比如一个类的名字、类的字段名字/所属数据类型、方法名称/返回类型/参数名、常量、字面量等。这些在JVM解释执行程序的时候非常重要,所以编译器将源代码编译成class文件之后,会用一部分字节分类存储这些不变的代码,而这些字节我们就称为常量池。

运行时常量池

运行时常量池是每一个类或者接口的常量池(Constant Pool)的运行时的表现形式。

我们知道,一个类的加载过程,会经过:加载连接(验证、准备、解析)初始化的过程,而在类加载这个阶段,需要做以下几件事情:

  1. 通过一个类的全类限定名获取此类的二进制字节流。
  2. 在堆内存生成一个java.lang.Class对象,代表加载这个类,做为这个类的入口。
  3. class字节流的静态存储结构转化成方法区(元空间)的运行时数据结构。

而其中第三点,将class字节流代表的静态储存结构转化为方法区的运行时数据结构这个过程,就包含了class文件常量池进入运行时常量池的过程。

所以,运行时常量池的作用是存储class文件常量池中的符号信息,在类的解析阶段会把这些符号引用转换成直接引用(实例对象的内存地址),翻译出来的直接引用也是存储在运行时常量池中。class文件常量池的大部分数据会被加载到运行时常量池。

image-20211106202558917

运行时常量池保存在方法区(JDK1.8元空间)中,它是全局共享的,不同的类共用一个运行时常量池。

另外,运行时常量池具有动态性的特征,它的内容并不是全部来源与编译后的class文件,在运行时也可以通过代码生成常量并放入运行时常量池。比如String.intern()方法。

字符串常量池

字符串常量池,简单来说就是专门针对String类型设计的常量池。

字符串常量池的常用创建方式有两种。

String a="Hello"; String b=new String("Mic");
  1. a这个变量,是在编译期间就已经确定的,会进入到字符串常量池。
  2. b这个变量,是通过new关键字实例化,new是创建一个对象实例并初始化该实例,因此这个字符串对象是在运行时才能确定的,创建的实例在堆空间上。

字符串常量池存储在堆内存空间中,创建形式如下图所示。

image-20211106235703069

当使用String a="Hello"这种方式创建字符串对象时,JVM首先会先检查该字符串对象是否存在与字符串常量池中,如果存在,则直接返回常量池中该字符串的引用。否则,会在常量池中创建一个新的字符串,并返回常量池中该字符串的引用。(这种方式可以减少同一个字符串被重复创建,节约内存,这也是享元模式的体现)。

如下图所示,如果再通过String c="Hello"创建一个字符串,发现常量池已经存在了Hello这个字符串,则直接把该字符串的引用返回即可。(String里面的享元模式设计)

image-20211107001801733

当使用String b=new String("Mic")这种方式创建字符串对象时,由于String本身的不可变性(后续分析),因此在JVM编译过程中,会把Mic放入到Class文件的常量池中,在类加载时,会在字符串常量池中创建Mic这个字符串。接着使用new关键字,在堆内存中创建一个String对象并指向常量池中Mic字符串的引用。

如下图所示,如果再通过new String("Mic")创建一个字符串对象,此时由于字符串常量池已经存在Mic,所以只需要在堆内存中创建一个String对象即可。

image-20211107002344014

简单总结一下:JVM之所以单独设计字符串常量池,是JVM为了提高性能以及减少内存开销的一些优化:

  1. String对象作为Java语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。
  2. 创建字符串常量时,首先检查字符串常量池是否存在该字符串,如果有,则直接返回该引用实例,不存在,则实例化该字符串放入常量池中。
字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作"被驻留的字符串"或"interned string"或通常所说的"进入了字符串常量池的字符串"!

封装类常量池

除了字符串常量池,Java的基本类型的封装类大部分也都实现了常量池。包括Byte,Short,Integer,Long,Character,Boolean

注意,浮点数据类型Float,Double是没有常量池的。

封装类的常量池是在各自内部类中实现的,比如IntegerCache(Integer的内部类)。要注意的是,这些常量池是有范围的:

  • Byte,Short,Integer,Long : [-128~127]
  • Character : [0~127]
  • Boolean : [True, False]

测试代码如下:

public static void main(String[] args) {   Character a=129;   Character b=129;   Character c=120;   Character d=120;   System.out.println(a==b);   System.out.println(c==d);   System.out.println("...integer...");   Integer i=100;   Integer n=100;   Integer t=290;   Integer e=290;   System.out.println(i==n);   System.out.println(t==e); }

运行结果:

false true ...integer... true false

封装类的常量池,其实就是在各个封装类里面自己实现的缓存实例(并不是JVM虚拟机层面的实现),如在Integer中,存在IntegerCache,提前缓存了-128~127之间的数据实例。意味着这个区间内的数据,都采用同样的数据对象。这也是为什么上面的程序中,通过==判断得到的结果为true

这种设计其实就是享元模式的应用。
private static class IntegerCache {   static final int low = -128;   static final int high;   static final Integer cache[];    static {     // high value may be configured by property     int h = 127;     String integerCacheHighPropValue =       sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");     if (integerCacheHighPropValue != null) {       try {         int i = parseInt(integerCacheHighPropValue);         i = Math.max(i, 127);         // Maximum array size is Integer.MAX_VALUE         h = Math.min(i, Integer.MAX_VALUE - (-low) -1);       } catch( NumberFormatException nfe) {         // If the property cannot be parsed into an int, ignore it.       }     }     high = h;      cache = new Integer[(high - low) + 1];     int j = low;     for(int k = 0; k < cache.length; k++)       cache[k] = new Integer(j++);      // range [-128, 127] must be interned (JLS7 5.1.7)     assert IntegerCache.high >= 127;   }    private IntegerCache() {} }

封装类常量池的设计初衷其实String相同,也是针对频繁使用的数据区间进行缓存,避免频繁创建对象的内存开销。

关于字符串常量池的问题探索

在上述常量池中,关于String字符串常量池的设计,还有很多问题需要探索:

  1. 如果常量池中已经存在某个字符串常量,后续定义相同字符串的字面量时,是如何指向同一个字符串常量的引用?也就是下面这段代码的断言结果是true

    String a="Mic"; String b="Mic"; assert(a==b); //true
  2. 字符串常量池的容量到底有多大?
  3. 为什么要设计针对字符串单独设计一个常量池?

为什么要设计针对字符串单独设计一个常量池?

首先,我们来看一下String的定义。

public final class String     implements java.io.Serializable, Comparable<String>, CharSequence {     /** The value is used for character storage. */     private final char value[];      /** Cache the hash code for the string */     private int hash; // Default to 0 }

从上述源码中可以发现。

  1. String这个类是被final修饰的,代表该类无法被继承。
  2. String这个类的成员属性value[]也是被final修饰,代表该成员属性不可被修改。

因此String具有不可变的特性,也就是说String一旦被创建,就无法更改。这么设计的好处有几个。

  1. 方便实现字符串常量池: 在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,String pool将不能够实现!
  2. 线程安全性,在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。
  3. 保证 hash 属性值不会频繁变更。确保了唯一性,使得类似HashMap容器才能实现相应的key-value缓存功能,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
注意,由于String的不可变性可以方便实现字符串常量池这一点很重要,这时实现字符串常量池的前提。

字符串常量池,其实就是享元模式的设计,它和在JDK中提供的IntegerCache、以及Character等封装对象的缓存设计类似,只是String是JVM层面的实现。

字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为 了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串池,每当代码创建字符串常量时,JVM会首先检查字符串常量池。如果字符串已经存在池中, 就返回池中的实例引用。如果字符串不在池中,就会实例化一个字符串并放到池中。Java能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突 进行共享。

我们把字符串常量池当成是一个缓存,通过双引号定义一个字符串常量时,首先从字符串常量池中去查找,找到了就直接返回该字符串常量池的引用,否则就创建一个新的字符串常量放在常量池中。

常量池有多大呢?

我想大家一定和我一样好奇,常量池到底能存储多少个常量?

前面我们说过,常量池本质上是一个hash表,这个hash表示不可动态扩容的。也就意味着极有可能出现单个 bucket 中的链表很长,导致性能降低。

在JDK1.8中,这个hash表的固定Bucket数量是60013个,我们可以通过下面这个参数配置指定数量

-XX:StringTableSize=N

可以增加下面这个虚拟机参数,来打印常量池的数据。

-XX:+PrintStringTableStatistics

增加参数后,运行下面这段代码。

public class StringExample {     private int value = 1;     public final static int fs=101;      public static void main(String[] args) {         final String a="ab";         final String b="a"+"b";         String c=a+b;     } }

在JVM退出时,会打印常量池的使用情况如下:

SymbolTable statistics: Number of buckets       :     20011 =    160088 bytes, avg   8.000 Number of entries       :     12192 =    292608 bytes, avg  24.000 Number of literals      :     12192 =    470416 bytes, avg  38.584 Total footprint         :           =    923112 bytes Average bucket size     :     0.609 Variance of bucket size :     0.613 Std. dev. of bucket size:     0.783 Maximum bucket size     :         6 StringTable statistics: Number of buckets       :     60013 =    480104 bytes, avg   8.000 Number of entries       :       889 =     21336 bytes, avg  24.000 Number of literals      :       889 =     59984 bytes, avg  67.474 Total footprint         :           =    561424 bytes Average bucket size     :     0.015 Variance of bucket size :     0.015 Std. dev. of bucket size:     0.122 Maximum bucket size     :         2

可以看到字符串常量池的总大小是60013,其中字面量是889

字面量是什么时候进入到字符串常量池的

字符串字面量,和其他基本类型的字面量或常量不同,并不会在类加载中的解析(resolve) 阶段填充并驻留在字符串常量池中,而是以特殊的形式存储在 运行时常量池(Run-Time Constant Pool) 中。而是只有当此字符串字面量被调用时(如对其执行ldc字节码指令,将其添加到栈顶),HotSpot VM才会对其进行resolve,为其在字符串常量池中创建对应的String实例。

具体来说,应该是在执行ldc指令时(该指令表示int、float或String型常量从常量池推送至栈顶)

在JDK1.8的HotSpot VM中,这种未真正解析(resolve)的String字面量,被称为pseudo-string,以JVM_CONSTANT_String的形式存放在运行时常量池中,此时并未为其创建String实例。

在编译期,字符串字面量以"CONSTANT_String_info"+"CONSTANT_Utf8_info"的形式存放在class文件的 常量池(Constant Pool) 中;

在类加载之后,字符串字面量以"JVM_CONSTANT_UnresolvedString(JDK1.7)"或者"JVM_CONSTANT_String(JDK1.8)"的形式存放在 运行时常量池(Run-time Constant Pool) 中;

在首次使用某个字符串字面量时,字符串字面量以真正的String对象的方式存放在 字符串常量池(String Pool) 中。

通过下面这段代码可以证明。

public static void main(String[] args) {   String a =new String(new char[]{'a','b','c'});   String b = a.intern();   System.out.println(a == b);    String x =new String("def");   String y = x.intern();   System.out.println(x == y); }

使用new char[]{'a','b','c'}构建的字符串,并没有在编译的时候使用常量池,而是在调用a.intern()时,将abc保存到常量池并返回该常量池的引用。

intern()方法

在Integer中的valueOf方法中,我们可以看到,如果传递的值i是在IntegerCache.lowIntegerCache.high范围以内,则直接从IntegerCache.cache中返回缓存的实例对象。

public static Integer valueOf(int i) {   if (i >= IntegerCache.low && i <= IntegerCache.high)     return IntegerCache.cache[i + (-IntegerCache.low)];   return new Integer(i); }

那么,在String类型中,既然存在字符串常量池,那么有没有方法能够实现类似于IntegerCache的功能呢?

答案是:intern()方法。由于字符串池是虚拟机层面的技术,所以在String的类定义中并没有类似IntegerCache这样的对象池,String类中提及缓存/池的概念只有intern() 这个方法。

/**      * Returns a canonical representation for the string object.      * <p>      * A pool of strings, initially empty, is maintained privately by the      * class {@code String}.      * <p>      * When the intern method is invoked, if the pool already contains a      * string equal to this {@code String} object as determined by      * the {@link #equals(Object)} method, then the string from the pool is      * returned. Otherwise, this {@code String} object is added to the      * pool and a reference to this {@code String} object is returned.      * <p>      * It follows that for any two strings {@code s} and {@code t},      * {@code s.intern() == t.intern()} is {@code true}      * if and only if {@code s.equals(t)} is {@code true}.      * <p>      * All literal strings and string-valued constant expressions are      * interned. String literals are defined in section 3.10.5 of the      * <cite>The Java&trade; Language Specification</cite>.      *      * @return  a string that has the same contents as this string, but is      *          guaranteed to be from a pool of unique strings.      */ public native String intern();

这个方法的作用是:去拿String的内容去Stringtable里查表,如果存在,则返回引用,不存在,就把该对象的"引用"保存在Stringtable表里

比如下面这段程序:

public static void main(String[] args) {   String str = new String("Hello World");   String str1=str.intern();   String str2 = "Hello World";   System.out.print(str1 == str2); }

运行的结果为:true。

实现逻辑如下图所示,str1通过调用str.intern()去常量池表中获取Hello World字符串的引用,接着str2通过字面量的形式声明一个字符串常量,由于此时Hello World已经存在于字符串常量池中,所以同样返回该字符串常量Hello World的引用,使得str1str2具有相同的引用地址,从而运行结果为true

image-20211107151916196

总结:intern方法会从字符串常量池中查询当前字符串是否存在:

  • 若不存在就会将当前字符串放入常量池中,并返回当地字符串地址引用。
  • 如果存在就返回字符串常量池那个字符串地址。

注意,所有字符串字面量在初始化时,会默认调用intern()方法。

这段程序,之所以a==b,是因为声明a时,会通过intern()方法去字符串常量池中查找是否存在字符串Hello,由于不存在,则会创建一个。同理,变量b也同样如此,所以b在声明时,发现字符常量池中已经存在Hello的字符串常量,所以直接返回该字符串常量的引用。

public static void main(String[] args) {   String a="Hello";   String b="Hello"; }

OK,学习到这里,是不是感觉自己懂了?我出一道题目来考考大家,下面这段程序的运行结果是什么?

public static void main(String[] args) {   String a =new String(new char[]{'a','b','c'});   String b = a.intern();   System.out.println(a == b);    String x =new String("def");   String y = x.intern();   System.out.println(x == y); }

正确答案是:

true false

第二个输出为false还可以理解,因为new String("def")会做两件事:

  1. 在字符串常量池中创建一个字符串def
  2. new关键字创建一个实例对象string,并指向字符串常量池def的引用。

x.intern(),是从字符串常量池获取def的引用,他们的指向地址不同,我后面的内容还会详细解释。

第一个输出结果为true是为啥捏?

JDK文档中关于intern()方法的说明:当调用intern方法时,如果常量池(内置在 JVM 中的)中已经包含相同的字符串,则返回池中的字符串。否则,将此String对象添加到池中,并返回对该String对象的引用。

在构建String a的时候,使用new char[]{'a','b','c'}初始化字符串时(不会自动调用intern(),字符串采用懒加载方式进入到常量池),并没有在字符串常量池中构建abc这个字符串实例。所以当调用a.intern()方法时,会把该String对象添加到字符常量池中,并返回对该String对象的引用,所以ab指向的引用地址是同一个。

问题回答

面试题:String a = "ab"; String b = "a" + "b"; a == b 是否相等

回答a==b是相等的,原因如下:

  1. 变量ab都是常量字符串,其中b这个变量,在编译时,由于不存在可变化的因素,所以编译器会直接把变量b赋值为ab(这个是属于编译器优化范畴,也就是编译之后,b会保存到Class常量池中的字面量)。
  2. 对于字符串常量,初始化a时, 会在字符串常量池中创建一个字符串ab并返回该字符串常量池的引用。
  3. 对于变量b,赋值ab时,首先从字符串常量池中查找是否存在相同的字符串,如果存在,则返回该字符串引用。
  4. 因此,a和b所指向的引用是同一个,所以a==b成立。

问题总结

关于常量池部分的内容,要比较深入和全面的理解,还是需要花一些时间的。

比如大家通过阅读上面的内容,认为对字符串常量池有一个非常深入的理解,可以,我们再来看一个问题:

public static void main(String[] args) {   String str = new String("Hello World");   String str1=str.intern();   System.out.print(str == str1); }

上面这段代码,很显然返回false,原因如下图所示。很明显strstr1所指向的引用地址不是同一个。

image-20211107155237442

但是我们把上述代码改造一下:

public static void main(String[] args) {   String str = new String("Hello World")+new String("!");   String str1=str.intern();   System.out.print(str == str1); }

上述程序输出的结果变成了:true。 为什么呢?

这里也是JVM编译器层面做的优化,因为String是不可变类型,所以理论上来说,上述程序的执行逻辑是:通过+进行字符串拼接时,相当于把原有的String变量指向的字符串常量HelloWorld取出来,加上另外一个String变量指向的字符串常量!,再生成一个新的对象。

假设我们是通过for循环来对String变量进行拼接,那将会生成大量的对象,如果这些对象没有被及时回收,会造成非常大的内存浪费。

所以JVM优化之后,其实是通过StringBuilder来进行拼接,也就是只会产生一个对象实例StringBuilder,然后再通过append方法来拼接。

为了证明我说的情况,来看一下上述代码的字节码。

 public static void main(java.lang.String[]);     descriptor: ([Ljava/lang/String;)V     flags: ACC_PUBLIC, ACC_STATIC     Code:       stack=4, locals=3, args_size=1          0: new           #3                  // class java/lang/StringBuilder          3: dup          4: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V          7: new           #5                  // class java/lang/String         10: dup         11: ldc           #6                  // String Hello World         13: invokespecial #7                  // Method java/lang/String."<init>":(Ljava/lang/String;)V         16: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;         19: new           #5                  // class java/lang/String         22: dup         23: ldc           #9                  // String !         25: invokespecial #7                  // Method java/lang/String."<init>":(Ljava/lang/String;)V         28: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;         31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;         34: astore_1         35: aload_1         36: invokevirtual #11                 // Method java/lang/String.intern:()Ljava/lang/String;         39: astore_2         40: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;         43: aload_1         44: aload_2         45: if_acmpne     52         48: iconst_1         49: goto          53         52: iconst_0         53: invokevirtual #13                 // Method java/io/PrintStream.print:(Z)V         56: return 

从字节码中可以看到,构建了一个StringBuilder,

 0: new           #3                  // class java/lang/StringBuilder

然后把字符串常量通过append方法进行拼接,最后调用toString()方法得到一个字符串常量。

16: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 28: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

因此,上述代码,等价于下面这种形式。

public static void main(String[] args) {   StringBuilder sb=new StringBuilder().append(new String("Hello World")).append(new String("!"));   String str=sb.toString();   String str1=str.intern();   System.out.print(str == str1); }

所以,得到的结果是true

基于这个问题的变体还有很多,比如再来变一次,下面这段程序的运行结果是多少?
public static void main(String[] args) {   String s1 = "a";   String s2 = "b";   String s3 = "ab";   String s4 = s1 + s2;   System.out.println(s3 == s4); }

答案是false

因为上述程序等价于, s3s4指向不同的地址引用,自然不相等。

public static void main(String[] args) {   String s1 = "a";   String s2 = "b";   String s3 = "ab";   StringBuilder sb=new StringBuilder().append(s1).append(s2);   String s4 = sb.toString();   System.out.println(s3 == s4); }
总结: 只有足够清晰的理解了字符串常量池相关的所有知识点,不管面试过程中如何变化,你都能准确回答,这就是知识的力量!
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Mic带你学架构
如果本篇文章对您有帮助,还请帮忙点个关注和赞,您的坚持是我不断创作的动力。欢迎关注同名微信公众号获取更多技术干货!

借助友盟+U-APM实现终端卡顿优化的全记录

Posted: 05 Nov 2021 03:10 AM PDT

目前手机SOC的性能越来越少,很多程序员在终端程序的开发过程中也不太注意性能方面的优化,尤其是不注意对齐和分支优化,但是这两种问题一旦出现所引发的问题,是非常非常隐蔽难查的,不过好在项目中用到了移动端的性能排查神器友盟U-APM工具的支持下,最终几个问题得到了圆满解决。

我们先来看对齐的问题,对齐在没有并发竞争的情况下不会有什么问题,编译器一般都会帮助程序员按照CPU字长进行对齐,但这在终端多线程同时工作的情况下可能会隐藏着巨大的性能问题,在多线程并发的情况下,即使没有共享变量,也可能会造成伪共享,由于具体的代码涉密,因此我们来看以下抽象后的代码。
public class Main {

public static void main(String[] args) {     final MyData data = new MyData();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();
try{
Thread.sleep(100);
} catch (InterruptedException e){
e.printStackTrace();
}

long[][] arr=data.Getitem();
System.out.println("arr0 is "+arr[0]+"arr1 is"+arr[1]);

}

}
class MyData {
private long[] arr={0,0};

public long[] Getitem(){
return arr;
}

public void add(int j){
for (;true;){
arr[j]++;
}
}
}

在这段代码中,两个子线程执行类似任务,分别操作arr数组当中的两个成员,由于两个子线程的操作对象分别是arr[0]和arr[1]并不存在交叉的问题,因此当时判断判断不会造成并发竞争问题,也没有加synchronized关键字。

但是这段程序却经常莫名的卡顿,后来经过多方的查找,并最终通过友盟的卡顿分析功能我们最终定位到了上述代码段,发现这是一个由于没有按照缓存行进行对齐而产生的问题,这里先将修改完成后的伪代码向大家说明一下:

public class Main {

public static void main(String[] args) {     final MyData data = new MyData();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();
try{
Thread.sleep(10);
} catch (InterruptedException e){
e.printStackTrace();
}

long[][] arr=data.Getitem();
System.out.println("arr0 is "+arr0+"arr1 is"+arr1);

}

}
class MyData {
private long[][] arr={{0,0,0,0,0,0,0,0,0},{0,0}};

public long[][] Getitem(){
return arr;
}

public void add(int j){
for (;true;){
arrj++;
}
}
}

可以看到整体程序没有作何变化,只是将原来的数组变成了二维数组,其中除了第一个数组中除arr0元素外,其余arr0-a0元素除完全不起作何与程序运行有关的作用,但就这么一个小小的改动,却带来了性能有了接近20%的大幅提升,如果并发更多的话提升幅度还会更加明显。

缓存行对齐排查分析过程

首先我们把之前代码的多线程改为单线程串行执行,结果发现效率与原始的代码一并没有差很多,这就让我基本确定了这是一个由伪共享引发的问题,但是我初始代码中并没有变量共享的问题,所以这基本可以判断是由于对齐惹的祸。
现代的CPU一般都不是按位进行内存访问,而是按照字长来访问内存,当CPU从内存或者磁盘中将读变量载入到寄存器时,每次操作的最小单位一般是取决于CPU的字长。比如8位字是1字节,那么至少由内存载入1字节也就是8位长的数据,再比如32位CPU每次就至少载入4字节数据, 64位系统8字节以此类推。那么以8位机为例咱们来看一下这个问题。假如变量1是个bool类型的变量,它占用1位空间,而变量2为byte类型占用8位空间,假如程序目前要访问变量2那么,第一次读取CPU会从开始的0x00位置读取8位,也就是将bool型的变量1与byte型变量2的高7位全部读入内存,但是byte变量的最低位却没有被读进来,还需要第二次的读取才能把完整的变量2读入。

也就是说变量的存储应该按照CPU的字长进行对齐,当访问的变量长度不足CPU字长的整数倍时,需要对变量的长度进行补齐。这样才能提升CPU与内存间的访问效率,避免额外的内存读取操作。但在对齐方面绝大多数编译器都做得很好,在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间边界。也可以通过pragma pack(n)调用来改变缺省的对界条件指令,调用后C编译器将按照pack(n)中指定的n来进行n个字节的对齐,这其实也对应着汇编语言中的.align。那么为什么还会有伪共享的对齐问题呢?

现代CPU中除了按字长对齐还需要按照缓存行对齐才能避免并发环境的竞争,目前主流ARM核移动SOC的缓存行大小是64byte,因为每个CPU都配备了自己独享的一级高速缓存,一级高速缓存基本是寄存器的速度,每次内存访问CPU除了将要访问的内存地址读取之外,还会将前后处于64byte的数据一同读取到高速缓存中,而如果两个变量被放在了同一个缓存行,那么即使不同CPU核心在分别操作这两个独立变量,而在实际场景中CPU核心实际也是在操作同一缓存行,这也是造成这个性能问题的原因。

Switch的坑

但是处理了这个对齐的问题之后,我们的程序虽然在绝大多数情况下的性能都不错,但是还是会有卡顿的情况,结果发现这是一个由于Switch分支引发的问题。
switch是一种我们在java、c等语言编程时经常用到的分支处理结构,主要的作用就是判断变量的取值并将程序代码送入不同的分支,这种设计在当时的环境下非常的精妙,但是在当前最新的移动SOC环境下运行,却会带来很多意想不到的坑。
出于涉与之前密的原因一样,真实的代码不能公开,我们先来看以下这段代码:
public class Main {

public static void main(String[] args) {     long now=System.currentTimeMillis(); 

int max=100,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is "+a+"b is "+b+"c is "+c);

}

}

其中随机数其实是一个rpc远程调用的返回,但是这段代码总是莫名其妙的卡顿,为了复现这个卡顿,定位到这个代码段也是通过友盟U-APM的卡顿分析找到的,想复现这个卡顿只需要我们再稍微把max范围由调整为5。
public class Main {

public static void main(String[] args) {     long now=System.currentTimeMillis(); 

int max=5,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is "+a+"b is "+b+"c is "+c);

}

}

那么运行时间就会有30%的下降,不过从我们分析的情况来看,代码一平均每个随机数有97%的概念要行2次判断才能跳转到最终的分支,总体的判断语句执行期望为20.97+10.03约等于2,而代码二有30%的概念只需要1次判断就可以跳转到最终分支,总体的判断执行期望也就是0.31+0.62=1.5,但是代码二却反比代码一还慢30%。也就是说在代码逻辑完全没变只是返回值范围的概率密度做一下调整,就会使程序的运行效率大大下降,要解释这个问题要从指令流水线说起。

指令流水线原理

我们知道CPU的每个动作都需要用晶体震荡而触发,以加法ADD指令为例,想完成这个执行指令需要取指、译码、取操作数、执行以及取操作结果等若干步骤,而每个步骤都需要一次晶体震荡才能推进,因此在流水线技术出现之前执行一条指令至少需要5到6次晶体震荡周期才能完成

为了缩短指令执行的晶体震荡周期,芯片设计人员参考了工厂流水线机制的提出了指令流水线的想法,由于取指、译码这些模块其实在芯片内部都是独立的,完成可以在同一时刻并发执行,那么只要将多条指令的不同步骤放在同一时刻执行,比如指令1取指,指令2译码,指令3取操作数等等,就可以大幅提高CPU执行效率:

以上图流水线为例 ,在T5时刻之前指令流水线以每周期一条的速度不断建立,在T5时代以后每个震荡周期,都可以有一条指令取结果,平均每条指令就只需要一个震荡周期就可以完成。这种流水线设计也就大幅提升了CPU的运算速度。
但是CPU流水线高度依赖指指令预测技术,假如在流水线上指令5本是不该执行的,但却在T6时刻已经拿到指令1的结果时才发现这个预测失败,那么指令5在流水线上将会化为无效的气泡,如果指令6到8全部和指令5有强关联而一并失效的话,那么整个流水线都需要重新建立。

所以可以看出例子当中的这个效率差完全是CPU指令预测造成的,也就是说CPU自带的机制就是会对于执行概比较高的分支给出更多的预测倾斜。
处理建议-用哈希表替代switch
我们上文也介绍过哈希表也就是字典,可以快速将键值key转化为值value,从某种程度上讲可以替换switch的作用,按照第一段代码的逻辑,用哈希表重写的方案如下:
import java.util.HashMap;
public class Main {

public static void main(String[] args) {     long now=System.currentTimeMillis(); 

int max=6,min=0;
HashMap<Integer,Integer> hMap = new HashMap<Integer,Integer>();
hMap.put(0,0);
hMap.put(1,0);
hMap.put(2,0);
hMap.put(3,0);
hMap.put(4,0);
hMap.put(5,0);
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
int value = hMap.get(ran)+1;

hMap.replace(ran,value);
}

long diff=System.currentTimeMillis()-now;
System.out.println(hMap);
System.out.println("time is "+ diff);

}

}

上述这段用哈希表的代码虽然不如代码一速度快,但是总体非常稳定,即使出现代码二的情况也比较平稳。

经验总结

一、有并发的终端编程一定要注意按照缓存行(64byte)对齐,不按照缓存行对齐的代码就是每增加一个线程性能会损失20%。
二、重点关注switch、if-else分支的问题,一旦条件分支的取值条件有所变化,那么应该首选用哈希表结构,对于条件分支进行优化。
三、选择一款好用的性能监测工具,如:友盟U-APM,不仅免费且捕获类型较为全面,推荐大家使用。

作者:马占杰

gRPC PHP与GO 数据增长性能测试与分析

Posted: 07 Nov 2021 01:08 AM PDT

前言

大家好,我是CrazyCodes,最近调研了下关于PHP通过gRPC请求go,与PHP通过HTTP方式请求,根据数据量不断增长的情况下,平均响应时间会有多大差距。

一致性声明

测试报告全部由开发机测试得出,未进行任何配置更改。

本机配置

  • 3 GHz 六核Intel Core i5
  • 16 GB 2400 MHz DDR4
  • MacOs

Go启动HTTP

http.ListenAndServe

PHP启动HTTP

php -S 127.0.0.1:8088

Ab

ab -c 200 -n 200 -k

测试数据说明
Go作为服务端,是通过for直接迭代出的测试数据,没有任何其他多余操作

// 伪代码 for i := 0; i < 2,20,200,2000,5000; i++ {  }

其他说明

下方GPH[数字],代表gRPC + PHP + HTTP + 数据量

GPH 2

返回数据集大小一致

gRPC Document Length≈87bytes
Http Document Length≈315bytes

Go Grpc -> Go Grpc 平均响应时间为66.215ms

go-2.png

Php Grpc -> Go Grpc 平均响应时间为190.551ms

php-2.png

Php Http -> Go Http 平均响应时间为120.692ms

php-http-2.png

GPH 20

返回数据集大小一致

gRPC Document Length≈556bytes
Http Document Length≈2198bytes

Go Grpc -> Go Grpc 平均响应时间为71.525ms

go-20.png

Php Grpc -> Go Grpc 平均响应时间为212.019ms

php-20.png

Php Http -> Go Http 平均响应时间为138.332ms

php-http-20.png

GPH 200

返回数据集大小一致

gRPC Document Length≈5527bytes
Http Document Length≈21199bytes

Go Grpc -> Go Grpc 平均响应时间为93.963ms

go-200.png

Php Grpc -> Go Grpc 平均响应时间为250.104ms

php-200.png

Php Http -> Go Http 平均响应时间为175.916ms

php-http-200.png

GPH 2000

返回数据集大小一致

gRPC Document Length≈56928bytes
Http Document Length≈213000bytes

Go Grpc -> Go Grpc 平均响应时间为379.699ms

go-2000.png

Php Grpc -> Go Grpc 平均响应时间为678.643ms

php-2000.png

Php Http -> Go Http 平均响应时间为593.252ms

php-http-2000.png

GPH 5000

返回数据集大小一致

gRPC Document Length≈143928bytes
Http Document Length≈534000bytes

Go Grpc -> Go Grpc 平均响应时间为920.897ms

go-5000.png

Php Grpc -> Go Grpc 平均响应时间为1406.589ms

php-5000.png

Php Http -> Go Http 平均响应时间为1285.640ms

php-http-5000.png

结论

Way22020020005000
PHP->gRPC190.551/ms212.332250.104678.6431406.589
Go->gRPC66.215/ms71.52593.963379.699920.897
PHP->Http120.692/ms138.332175.916593.2521285.640

根据测试结果,我们不难看出,再构建微服务时,PHP以gRPC的方式请求是最不理想的情况,而PHP以Http方式调用在响应时间来看,要比PHP以gRPC调用的方式稍微理想一些,但数据与并发不断增长的情况下,这个差距会不断变大,所以在决定使用PHP作为客户端时,还是以传统HTTP方式调用效果最佳。

挽尊

image.png

你肯定认为,PHP在做微服务上已经被碾压,但这其实并不是PHP本身的错误,我们都知道PHP是解释型语言,而Go是编译型语言,Go作为预先编译后,直接生成可执行文件,而PHP是动态编译。

那么在PHP通过gRPC调用为什么会这么慢?这取决于我们使用的谷歌官方提供的包,这个包将数据封装在class内,而class并不像Go的结构体那么小巧玲珑,当数据不断增长后,这个class会指数级扩大,而我们看到PHP通过HTTP调用时,虽然内容体是gRPC调用的好几倍,但时间反而变小,这其中的原因也很简单,通过HTTP调用,先要进行通信,并且封装HTTP头,内容体等等,这就造成了请求本体很大,但因为是直接请求一个结果,并没有通过class,所以时间要低于gRPC调用,这是PHP使用gRPC慢的原因之一。

其二就是我们所说的PHP本身的特性,PHP通过动态编译后向用户返回结果,这个过程也是需要时间的,而Go则不需要这段时间,所以这也是影响响应时间的关键因素。

致谢

image.png

感谢你看到这里,希望可以帮到你。

首次发布此类分析相关文章,可能有很多不足之处,接受各种调教。

No comments:

Post a Comment