文章目录
- JMM
- 类加载机制
- String底层
-
- string基本特性
- 字符串拼接操作
-
- [new String("ab")到底创建了几个对象?new String("a") + new String("b")呢?](#new String("ab")到底创建了几个对象?new String("a") + new String("b")呢?)
- String的intern()方法
- G1的String去重操作
- 垃圾回收
JMM
-
java的并发采用"共享内存"模型,线程之间通过读写内存的公共状态进行通讯,多个线程之间是不能通过直接传递数据交互的,他们之间交互只能通过共享变量来实现。
-
==JMM的主要目的是定义程序中各个变量的访问规则。==java线程之间的通信由JMM控制。JMM定义了JVM在计算机内存(RAM)中的工作方式,如果想深入理解java并发编程,就要先理解好java内存模型。
主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中的变量不是完全等同的。这里的变量指的是实例字段、静态字段、构成数组对象的元素,但不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,自然就不会存在竞争的问题。
- 主内存:java内存模型规定了所有的变量都存储在主内存中。
- 工作内存:每条线程有自己的工作内存(可与物理硬件处理器的高速缓存类比),线程的工作内存中保存了该线程所使用的变量的主内存拷贝副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程间也无法访问对方工作内存中的变量,线程间的变量传递需要通过主内存完成。
工作内存与主内存的交互的8种方法
Java内存模型定义了8种方法来完成主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存。虚拟机实现的时候,必须每一种操作都是原子的、不可再分的。
lock
(锁定):作用于主内存
的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量unlock
(解锁):作用于主内存
的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定read
(读取):作用于主内存变量
,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用。load
(载入):作用于线程的工作内存
的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)use
(使用):作用于线程的工作内存
中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作assign
(赋值):作用于线程的工作内存
的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作store
(存储):作用于线程的工作内存
中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用write
(写入):作用于主内存
的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。只需要保证相对顺序,不要求连续,下边两种执行结果是一样的:
- read a; read b; load b; load a;
- read a; load a; read b; load b;
在执行这8中操作的时候必须遵循如下的规则
- 不允许read和load、store和write操作必须成对出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
- 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
- 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
- 一个新的变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
- 一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
- 对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
- 不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
- 对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作
JVM内存结构
JVM将内存分为5大区域,程序计数器、虚拟机栈、本地方法栈、java堆、方法区;
- 程序计数器:线程私有,是一块很小的内存空间,作为当前线程的执行的代码行号指示器,用于记录当前虚拟机正在执行的线程指令地址。
- 虚拟机栈(Java栈):线程私有,每个方法执行的时候创建一个栈帧,用于存储局部变量表,操作数、动态链接和方法返回等信息,当栈深度超过了虚拟机允许的最大深度,就会抛出StackOverFlowError
- 本地方法栈:线程私有,保存的是native方法的信息,当一个JVM创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法。
- 堆:所有线程共享的一块内存,几乎所有对象的实例和数组都在堆上分配内存,因此堆区经常发生垃圾回收操作。
- 方法区 :存放已经加载的类信息,常量、静态变量、即时编译器编译后的代码数据。jdk1.8中不存在方法区了,被元数据区替代了,原方法区被分成两部分:1、加载类信息。2、运行时常量池。加载类信息保存在元数据区,运行时常量池保存在堆中。
灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 每个线程:独立包括程序计数器、栈、本地栈。
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
运行时数据区
类加载机制
从字节码加载类经历三个阶段:
- 加载 阶段:类加载器 负责从文件系统或者网络中加载class文件,加载的类信息存放于元空间 ,类加载器只负责class文件的加载,至于它是否可以执行,则由执行引擎 Execute Engine决定。
- 引导类加载器
- 扩展类加载器
- 系统类加载器
- 自定义类加载器
- 链接阶段
- 验证
- 准备
- 解析
- 初始化 阶段
类加载器
类加载分类
在程序获取引导类加载器或者String的类加载器,会发现得到结果为null,这是因为String属于Java的核心类,由引导类加载器加载,而引导类加载器是最高级的加载器,嵌套到JVM内部,无法在程序中获取。
获取类加载器的途径
双亲委派机制
Java虚拟机对class文件采用的是按需加载 的方式,当需要使用该类时,才会将它的class文件加载到内存生成的class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即将请求交由给父类来处理,它是一种任务委派模式。
- 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
双亲委派的优势在于:
- 1、避免类的重复加载
- 2、保护程序安全,防止核心API被随意篡改
- 例如自定义String类
对类加载器的引用
在JVM中表示两个class对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名。
- 加载这个类的classLoader(指cia sToader实例对象)必须相同。
换句话说,在JVM中,即使这两个类对象(class对象)来源同一个class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
JVM必须知道一个类型是由启加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
String底层
string基本特性
- String声明为final,不可被继承
- String实现了Serializable接口,支持序列化,实现了Compareable接口,支持比较大小。
- String在jdk8及以前内部定义了final char[] value, 用于存储字符串数据,jkd9时改为了byte[]。
- byte 是字节数据类型 ,是有符号型的,占1 个字节;大小范围为-128---127 。
- char 是字符数据类型 ,是无符号型的,占2字节(Unicode码 );大小范围 是0---65535 ;char是一个16位二进制的Unicode字符,JAVA用char来表示一个字符
大部分拉丁字符 都可以用一个byte表示,用两个字节的char,浪费了空间,但是类似汉字字符 ,还是必须用两个字节表示,即2个byte,那么使用一个byte就可以满足需求的,可以使用编码标记 (
end-flag
),表示已经编码结束。
- String的String Pool是一个固定大小 的Hashtable ,不可存放重复的字符串,默认值大小长度是1009,使用-XX:SstringTableSize,可设置StringTable的长度,1009是可设置的最小值。
- 使用字面值声明的String(
String a1 = "aaa";
)会直接放入字符串常量池,返回地址,而使用new String("aaa");
构造的String,会在堆区新建"aaa"的对象返回,同时入参"aaa"会被存入字符串常量池。
java
String a1 = "aaa";
String a2 = new String("aaa");
String a3 = "aaa";
String a4 = new String("aaa");
System.out.println(a1 == a2); // false
System.out.println(a2 == a3); // false
System.out.println(a1 == a3); // true
System.out.println(a2 == a4); // false
案例
前置知识1:
Java中,方法的参数传递只有值传递 ,但是对于引用类型,传递的是地址的值
。
前置知识2:Java数据类型:
因此题目中,change()方法传递的char[]数组和String,都是引用类型,也就是说传递的是地址。
那么,change()中对于char[]数组的修改是生效的,test 会变成 best。
但是对于String类型,它是不可变的,change()方法中对于str的赋值,底层会赋值一份str,再修改,而不会影响原有的str。
字符串拼接操作
-
1、常量与常量的拼接结果在常量池,原理是编译期优化
-
2、常量池不会存在相同内容的常量。
-
3、只要拼接其中有一个是变量,结果就在堆中new一个新的字符串,变量拼接的原理是StringBuilder 。
-
4、如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串变量放入池中,并返回此对象的地址。
new String("ab")到底创建了几个对象?new String("a") + new String("b")呢?
String的intern()方法
Intern()方法设计的初衷,就是重用String对象,以节省内存消耗。
str.intern()的作用:判断字符串常量池中是否存在str表示的字符串:
- 如果存在:返回常量池中的地址
- 如果不存在,则在常量池中记录str的引用,然后返回常量池中引用。
调用String.intern()方法后,堆中的字符串实例并不会立即被销毁,但是通过intern()方法返回的引用会被重新赋值为常量池中的引用,这可能会导致原本指向堆中字符串的引用失效,从而可能会被垃圾回收器回收,从而节省内存消耗.
java
String s1 = new String("hello");
String s2 = "hello";
String s3 = s1.intern();
System.out.println(s1 == s2); // false,不同的引用
System.out.println(s2 == s3); // true,共享常量池中的引用
- String s1 = new String("hello");
- hello存在于字符串常量池和堆中,s1的堆中对象的引用。
- String s2 = "hello";
- s2是字符串常量池中的引用
- String s3 = s1.intern();
- 常量池中存在s1引用的"hello",因此返回常量持有中"hello"的引用,s3引用的是字符串常量池中的"hello'
-
String s = newString("1"),生成了常量池中的"1" 和堆空间中的字符串对象。
-
s.intern(),这一行的作用是s对象去常量池中寻找后发现"1"已经存在于常量池中了。
-
String s2 = "1",这行代码是生成一个s2的引用指向常量池中的"1"对象。
结果就是 s 和 s2 的引用地址明显不同。因此返回了false。
-
String s3 = new String("1") + newString("1"),这行代码在字符串常量池中生成"1" ,并在堆空间中生成s3引用指向的对象(内容为"11")。注意此时常量池中是没有 "11"对象的。
-
s3.intern(),这一行代码,是将 s3中的"11"字符串放入 String 常量池中,此时常量池中不存在"11"字符串,JDK1.6的做法是直接在常量池中生成一个 "11" 的对象。
但是在JDK1.7中,常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用直接指向 s3 引用的对象,也就是说s3.intern() ==s3会返回true。
String s4 = "11", 这一行代码会直接去常量池中创建,但是发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。因此s3 == s4返回了true。
G1的String去重操作
垃圾回收
四种引用
-
强引用(StrongReference):最传统的"引用"的定义,是指在程序代码之中普遍存在的引用赋值,即类似"object obj=new object()"这种引用关系。无论任何情况下只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
-
软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常
- 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
-
弱引用(weakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
- 弱引用发现即回收,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
-
虚引用 (PhantomReference) :一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
- 虚引用必须和引用队列一起使用,在创建时必须为其提供一个引用队列,当GC时,如果发现一个对象还有虚引用,就会在对象回收后,把虚引用加入到引用队列。
垃圾标记
引用计数算法
可达性分析算法
所谓"GC Roots"根集合就是一组必须活跃的引用。
基本思路:
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象
才是存活对象。
在Java中,GC Roots包括如下几类元素:
- 虚拟机栈 中引用的对象
比如:各个线程被调用的方法中使用到的参数、局部变量等。 - 本地方法栈内JNI (通常说的本地方法)引用的对象
- 方法区中类静态属性引用的对象。比如: Java类的引用类型静态变量方法区中常量引用的对象,比如:字符串常量池(string Table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用 。比如基本数据类型对应的class对象,一些常驻的异常对象(如:
NullPointerException、outofMemoryError),系统类加载器。 - 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
对象的finalization机制
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
finalize ()方法允许在子类中被重写,用于在对象被回收时进行资源释放.通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
Java的finalization机制与C++的析构函数有一些相似之处,但也有所不同
垃圾回收
JVM的垃圾回收算法有哪些?
- 标记清除 (mark sweep):分为两步,第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记,第二步:在遍历一遍,将所有标记的对象回收掉;标记清除的缺点在于会产生大量内存碎片
- 分块拷贝算法 (copying):将内存按照容量分为大小相等的两块,每次只使用其中一块,当一块内存不足时,将还存活的对象移动到另外一块,然后把使用过的那块整体清除回收。分块拷贝算法的缺点在于浪费空间
- 标记压缩 (mark compact)类似于标记清除,但是标记清除的过程中对存活对象和垃圾对象集中整理,标记压缩不会产生没有内存碎片,但是效率极低
- 分代收集 算法:
如下图,将内存空间分为 2 / 3 的老年代(old),1 / 3 的新生代(young),其中young区又分出 8 / 10的 伊甸区(Eden),1 / 10的from幸存区和1 / 10 的to幸存区。
- 对象优先在Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(标记清除)。
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区,假定为from区;
- Eden 区再次 GC时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
- 对象每移动一次,年龄加1,当对象年龄大于一定阀值会直接移动到老年代,这个阈值默认是15,对象头用了4个字节记录对象年龄,因此对象年龄阈值不可大于15。
- 幸存区内存不足时,触发分配担保机制,超过指定大小的对象会直接进入老年代。
- 大对象如字符串、数组等需要大量连续内存空间的对象会直接进入老年代,避免为大对象分配内存时由于分配担保机制带来的复制导致的效率的降低。
- 当老年代容量不足时,会进行Full GC,对所有的线程STW(stop the world),对所有的区域执行标记清除。
- 分区收集算法:将整个堆空间划分为多个连续的小区间,在每个小区间里,独立使用,独立回收,这样可以减少一次GC所产生的停顿。
System.gc() / Runtime.getRuntime().gc()
System.gc()和Runtime.getRuntime().gc()都用于显式触发垃圾回收,用于建议JVM执行垃圾回收 。这种方式与System.gc()的效果是一样的,它也不能保证立即执行垃圾回收,仍然取决于JVM的决策。
内存溢出与内存泄露
内存溢出 :堆区没有空闲内存,并且垃圾收集器也无法提供更多的内存,称为内存溢出。
内存泄露 :对象不会再被程序用到了,但是GC又不能回收他们的情况,称为内存泄露。
安全点与安全区域
安全点:程序执行时并非在所有地方都能停顿下来开始Gc,只有在特定的位置才能停顿下来开始GC,这些位置称为"安全点(Safepoint) "。
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
- 主动式中断:
设置一个中断标志,各个线程运行到safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的 Safepoint 。但是,程序"不执行"的时候呢?例如线程处于Sleep 状态或Blocked状态,这时候线程无法响应JVM的中断请求,"走"到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域( Safe Region)来解决。
安全区域 :安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始cc都是安全的。我们也可以把 safe Region看做是被扩展了的safepoint。
垃圾回收器
垃圾回收器(Garbage Collector,GC)
分类
- 按线程数分:串行(Serial)和并行(Parallel)
- 按工作模式分:独占式(只能STW才能GC)和并发式(边运行边GC)
- 按碎片处理方式:压缩式和非压缩式
- 按工作的内存区间:年轻代和老年代
评估GC的性能指标
现在设计垃圾回收器的标准:在最大吞吐量优先的情况下,降低停顿时间。