目录重构

This commit is contained in:
youthlql
2021-12-06 22:31:03 +08:00
parent e40677d718
commit 4282b0e7ce
44 changed files with 37 additions and 43 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,574 +0,0 @@
---
title: JVM系列-第1章-JVM与Java体系结构
tags:
- JVM
- 虚拟机
categories:
- JVM
- 1.内存与垃圾回收篇
keywords: JVM虚拟机。
description: JVM系列-第1章-JVM与Java体系结构。
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/logo.png'
abbrlink: 8c954c6
date: 2020-11-02 11:51:56
---
> 1、本系列博客主要是面向Java8的虚拟机。如有特殊说明会进行标注。
>
> 2、本系列博客主要参考**尚硅谷的JVM视频教程**,整理不易,所以图片打上了一些水印,还请读者见谅。后续可能会加上一些补充的东西。
>
> 3、尚硅谷的有些视频还不错PS不是广告毕竟看了人家比较好的教程得给人家打个call
>
> 4、转载请注明出处多谢~,希望大家一起能维护一个良好的开源环境。
第1章-JVM和Java体系架构
=====================
前言
--------
你是否也遇到过这些问题?
1. 运行着的线上系统突然卡死系统无法访问甚至直接OOM
2. 想解决线上JVM GC问题但却无从下手。
3. 新项目上线对各种JVM参数设置一脸茫然直接默认吧然后就JJ了。
4. 每次面试之前都要重新背一遍JVM的一些原理概念性的东西然而面试官却经常问你在实际项目中如何调优VM参数如何解决GC、OOM等问题一脸懵逼。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0001.png">
大部分Java开发人员除了会在项目中使用到与Java平台相关的各种高精尖技术对于Java技术的核心Java虚拟机了解甚少。
开发人员如何看待上层框架
---------
1. 一些有一定工作经验的开发人员打心眼儿里觉得SSM、微服务等上层技术才是重点基础技术并不重要这其实是一种本末倒置的“病态”。
2. 如果我们把核心类库的API比做数学公式的话那么Java虚拟机的知识就好比公式的推导过程。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0002.png">
- 计算机系统体系对我们来说越来越远,在不了解底层实现方式的前提下,通过高级语言很容易编写程序代码。但事实上计算机并不认识高级语言。
架构师每天都在思考什么?
---------
1. 应该如何让我的系统更快?
2. 如何避免系统出现瓶颈?
**知乎上有条帖子应该如何看招聘信息直通年薪50万+**
1. 参与现有系统的性能优化,重构,保证平台性能和稳定性
2. 根据业务场景和需求,决定技术方向,做技术选型
3. 能够独立架构和设计海量数据下高并发分布式解决方案,满足功能和非功能需求
4. 解决各类潜在系统风险,核心功能的架构与代码编写
5. 分析系统瓶颈,解决各种疑难杂症,性能调优等
我们为什么要学习JVM
-----------
1. 面试的需要BATJ、TMDPKQ等面试都爱问
2. 中高级程序员必备技能
- 项目管理、调优的需要
3. 追求极客的精神,
- 比如垃圾回收算法、JIT、底层原理
Java VS C++
-------------
1. 垃圾收集机制为我们打理了很多繁琐的工作大大提高了开发的效率但是垃圾收集也不是万能的懂得JVM内部的内存结构、工作机制是设计高扩展性应用和诊断运行时问题的基础也是Java工程师进阶的必备能力。
2. C++语言需要程序员自己来分配内存和回收内存对于高手来说可能更加舒服但是对于普通开发者如果技术实力不够很容易造成内存泄漏。而Java全部交给JVM进行内存分配和回收这也是一种趋势减少程序员的工作量。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0003.png">
## 什么人需要学JVM
1. 拥有一定开发经验的Java开发人员希望升职加薪
2. 软件设计师,架构师
3. 系统调优人员
4. 虚拟机爱好者JVM实践者
推荐及参考书籍
------
**官方文档**
**英文文档规范**https://docs.oracle.com/javase/specs/index.html
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0004.png">
**中文书籍:**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0005.png">
> 周志明老师的这本书**非常推荐看**,不过只推荐看第三版,第三版较第二版更新了很多,个人觉得没必要再看第二版。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0006.png">
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0007.png">
TIOBE排行榜
-----------
**TIOBE 排行榜**https://www.tiobe.com/tiobe-index/
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0008.png">
- 世界上没有最好的编程语言,只有最适用于具体应用场景的编程语言。
- 目前网上一直流传Java被pythongo撼动Java第一的地位。学习者不需要太担心Java强大的生态圈也不是说是朝夕之间可以被撼动的。
Java生态圈
----------
Java是目前应用最为广泛的软件开发平台之一。随着Java以及Java社区的不断壮大Java 也早已不再是简简单单的一门计算机语言了,它更是一个平台、一种文化、一个社区。
1. 作为一个平台Java虚拟机扮演着举足轻重的作用
* Groovy、Scala、JRuby、Kotlin等都是Java平台的一部分
2. 作为一种文化Java几乎成为了“开源”的代名词。
* 第三方开源软件和框架。如Tomcat、StrutsMyBatisSpring等。
* 就连JDK和JVM自身也有不少开源的实现如openJDK、Harmony。
3. 作为一个社区Java拥有全世界最多的技术拥护者和开源社区支持有数不清的论坛和资料。从桌面应用软件、嵌入式开发到企业级应用、后台服务器、中间件都可以看到Java的身影。其应用形式之复杂、参与人数之众多也令人咋舌。
Java-跨平台的语言
------------
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0009.png">
JVM-跨语言的平台
------
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0010.png">
1. 随着Java7的正式发布Java虚拟机的设计者们通过JSR-292规范基本实现在Java虚拟机平台上运行非Java语言编写的程序。
2. Java虚拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性并不会单纯地与Java语言“终身绑定”只要其他编程语言的编译结果满足并包含Java虚拟机的内部指令集、符号表以及其他的辅助信息它就是一个有效的字节码文件就能够被虚拟机所识别并装载运行。
- Java不是最强大的语言但是JVM是最强大的虚拟机
1. 我们平时说的java字节码指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为jvm字节码。
2. 不同的编译器可以编译出相同的字节码文件字节码文件也可以在不同的JVM上运行。
3. Java虚拟机与Java语言并没有必然的联系它只与特定的二进制文件格式——Class文件格式所关联Class文件中包含了Java虚拟机指令集或者称为字节码、Bytecodes和符号表还有一些其他辅助信息。
多语言混合编程
----------
1. Java平台上的多语言混合编程正成为主流通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。
2. 试想一下在一个项目之中并行处理用Clojure语言编写展示层使用JRuby/Rails中间层则是Java每个应用层都将使用不同的编程语言来完成而且接口对每一层的开发者都是透明的各种语言之间的交互不存在任何困难就像使用自己语言的原生API一样方便因为它们最终都运行在一个虚拟机之上。
3. 对这些运行于Java虚拟机之上、Java之外的语言来自系统级的、底层的支持正在迅速增强以JSR-292为核心的一系列项目和功能改进如DaVinci Machine项目、Nashorn引擎、InvokeDynamic指令、java.lang.invoke包等推动Java虚拟机从“Java语言的虚拟机”向 “多语言虚拟机”的方向发展。
如何真正搞懂JVM
-----------
1. Java虚拟机非常复杂要想真正理解它的工作原理最好的方式就是自己动手编写一个
2. 自己动手写一个Java虚拟机难吗
3. 天下事有难易乎?为之,则难者亦易矣;不为,则易者亦难矣
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0011.png">
Java发展重大事件
------------
* 1990年在Sun计算机公司中由Patrick Naughton、MikeSheridan及James Gosling领导的小组Green Team开发出的新的程序语言命名为Oak后期命名为Java
* 1995年Sun正式发布Java和HotJava产品Java首次公开亮相。
* 1996年1月23日Sun Microsystems发布了JDK 1.0。
* 1998年JDK1.2版本发布。同时Sun发布了JSP/Servlet、EJB规范以及将Java分成了J2EE、J2SE和J2ME。这表明了Java开始向企业、桌面应用和移动设备应用3大领域挺进。
* 2000年JDK1.3发布Java HotSpot Virtual Machine正式发布成为Java的默认虚拟机。
* 2002年JDK1.4发布古老的Classic虚拟机退出历史舞台。
* 2003年年底Java平台的scala正式发布同年Groovy也加入了Java阵营。
* 2004年JDK1.5发布。同时JDK1.5改名为JavaSE5.0。
* 2006年JDK6发布。同年Java开源并建立了OpenJDK。顺理成章Hotspot虚拟机也成为了OpenJDK中的默认虚拟机。
* 2007年Java平台迎来了新伙伴Clojure。
* 2008年oracle收购了BEA得到了JRockit虚拟机。
* 2009年Twitter宣布把后台大部分程序从Ruby迁移到Scala这是Java平台的又一次大规模应用。
* 2010年Oracle收购了Sun获得Java商标和最真价值的HotSpot虚拟机。此时Oracle拥有市场占用率最高的两款虚拟机HotSpot和JRockit并计划在未来对它们进行整合HotRockit。JCP组织管理Java语言
* 2011年JDK7发布。在JDK1.7u4中正式启用了新的垃圾回收器G1。
* **2017年JDK9发布。将G1设置为默认GC替代CMS**
* 同年IBM的J9开源形成了现在的Open J9社区
* 2018年Android的Java侵权案判决Google赔偿Oracle计88亿美元
* 同年Oracle宣告JavagE成为历史名词JDBC、JMS、Servlet赠予Eclipse基金会
* **同年JDK11发布LTS版本的JDK发布革命性的ZGC调整JDK授权许可**
* 2019年JDK12发布加入RedHat领导开发的Shenandoah GC
## Open JDK和Oracle JDK
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0012.png">
- 在JDK11之前Oracle JDK中还会存在一些Open JDK中没有的闭源的功能。但在JDK11中我们可以认为Open JDK和Oracle JDK代码实质上已经达到完全一致的程度了。
- 主要的区别就是两者更新周期不一样
虚拟机
--------
### 虚拟机概念
- 所谓虚拟机Virtual Machine就是一台虚拟的计算机。它是一款软件用来执行一系列虚拟计算机指令。大体上虚拟机可以分为系统虚拟机和程序虚拟机。
- 大名鼎鼎的Virtual BoxVMware就属于系统虚拟机它们完全是对物理计算机硬件的仿真(模拟),提供了一个可运行完整操作系统的软件平台。
+ 程序虚拟机的典型代表就是Java虚拟机它专门为执行单个计算机程序而设计在Java虚拟机中执行的指令我们称为Java字节码指令。
- 无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
### Java虚拟机
1. Java虚拟机是一台执行Java字节码的虚拟计算机它拥有独立的运行机制其运行的Java字节码也未必由Java语言编译而成。
2. JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器以及可靠的即时编译器。
3. **Java技术的核心就是Java虚拟机**JVMJava Virtual Machine因为所有的Java程序都运行在Java虚拟机内部。
**作用:**
Java虚拟机就是二进制字节码的运行环境负责装载字节码到其内部解释/编译为对应平台上的机器指令执行。每一条Java指令Java虚拟机规范中都有详细定义如怎么取操作数怎么处理操作数处理结果放在哪里。
**特点:**
1. 一次编译,到处运行
2. 自动内存管理
3. 自动垃圾回收功能
JVM的位置
----------
JVM是运行在操作系统之上的它与硬件没有直接的交互
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0013.png">
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0014.png">
JVM的整体结构
-------------
1. HotSpot VM是目前市面上高性能虚拟机的代表作之一。
2. 它采用解释器与即时编译器并存的架构。
3. 在今天Java程序的运行性能早已脱胎换骨已经达到了可以和C/C++程序一较高下的地步。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0015.png">
Java代码执行流程
--------------
凡是能生成被Java虚拟机所能解释、运行的字节码文件那么理论上我们就可以自己设计一套语言了
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_001/0016.png">
JVM的架构模型
-----------
Java编译器输入的指令流基本上是一种**基于栈的指令集架构**,另外一种指令集架构则是**基于寄存器的指令集架构**。具体来说:这两种架构之间的区别:
### 基于栈的指令集架构
基于栈式架构的特点:
1. 设计和实现更简单,适用于资源受限的系统;
2. 避开了寄存器的分配难题:使用零地址指令方式分配
3. 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
4. 不需要硬件支持,可移植性更好,更好实现跨平台
### 基于寄存器的指令级架构
基于寄存器架构的特点:
1. 典型的应用是x86的二进制指令集比如传统的PC以及Android的Davlik虚拟机。
2. 指令集架构则完全依赖硬件,与硬件的耦合度高,可移植性差
3. 性能优秀和执行更高效
4. 花费更少的指令去完成一项操作
5. 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
### 两种架构的举例
同样执行2+3这种逻辑操作其指令分别如下
* **基于栈的计算流程以Java虚拟机为例**
```java
iconst_2 //常量2入栈
istore_1
iconst_3 // 常量3入栈
istore_2
iload_1
iload_2
iadd //常量2/3出栈执行相加
istore_0 // 结果5入栈
```
8个指令
* **而基于寄存器的计算流程**
```java
mov eax,2 //将eax寄存器的值设为1
add eax,3 //使eax寄存器的值加3
```
2个指令
> 具体后面会讲
### JVM架构总结
1. **由于跨平台性的设计Java的指令都是根据栈来设计的**。不同平台CPU架构不同所以不能设计为基于寄存器的。栈的优点跨平台指令集小编译器容易实现缺点是性能比寄存器差一些。
2. 时至今日尽管嵌入式平台已经不是Java程序的主流运行平台了准确来说应该是HotSpot VM的宿主环境已经不局限于嵌入式平台了那么为什么不将架构更换为基于寄存器的架构呢
- 因为基于栈的架构跨平台性好、指令集小,虽然相对于基于寄存器的架构来说,基于栈的架构编译得到的指令更多,执行性能也不如基于寄存器的架构好,但考虑到其跨平台性与移植性,我们还是选用栈的架构
JVM的生命周期
-----------
### 虚拟机的启动
Java虚拟机的启动是通过引导类加载器bootstrap class loader创建一个初始类initial class来完成的这个类是由虚拟机的具体实现指定的。
### 虚拟机的执行
1. 一个运行中的Java虚拟机有着一个清晰的任务执行Java程序
2. 程序开始执行时他才运行,程序结束时他就停止
3. **执行一个所谓的Java程序的时候真真正正在执行的是一个叫做Java虚拟机的进程**
### 虚拟机的退出
**有如下的几种情况:**
1. 程序正常执行结束
2. 程序在执行过程中遇到了异常或错误而异常终止
3. 由于操作系统用现错误而导致Java虚拟机进程终止
4. 某线程调用Runtime类或System类的exit()方法或Runtime类的halt()方法并且Java安全管理器也允许这次exit()或halt()操作。
5. 除此之外JNIJava Native Interface规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时Java虚拟机的退出情况。
JVM发展历程
-----------
### Sun Classic VM
1. 早在1996年Java1.0版本的时候Sun公司发布了一款名为sun classic VM的Java虚拟机它同时也是**世界上第一款商用Java虚拟机**JDK1.4时完全被淘汰。
2. 这款虚拟机内部只提供解释器,没有即时编译器,因此效率比较低。【即时编译器会把热点代码的本地机器指令缓存起来,那么以后使用热点代码的时候,效率就比较高】
3. 如果使用JIT编译器就需要进行外挂。但是一旦使用了JIT编译器JIT就会接管虚拟机的执行系统。解释器就不再工作解释器和编译器不能配合工作。
- 我们将字节码指令翻译成机器指令也是需要花时间的如果只使用JIT就需要把所有字节码指令都翻译成机器指令就会导致翻译时间过长也就是说在程序刚启动的时候等待时间会很长。
- 而解释器就是走到哪,解释到哪。
4. 现在Hotspot内置了此虚拟机。
### Exact VM
1. 为了解决上一个虚拟机问题jdk1.2时Sun提供了此虚拟机。
2. Exact Memory Management准确式内存管理
* 也可以叫Non-Conservative/Accurate Memory Management
* 虚拟机可以知道内存中某个位置的数据具体是什么类型。
3. 具备现代高性能虚拟机的维形
* 热点探测(寻找出热点代码进行缓存)
* 编译器与解释器混合工作模式
4. 只在Solaris平台短暂使用其他平台上还是classic vm英雄气短终被Hotspot虚拟机替换
### HotSpot VM重点
1. HotSpot历史
* 最初由一家名为“Longview Technologies”的小公司设计
* 1997年此公司被Sun收购2009年Sun公司被甲骨文收购。
* JDK1.3时HotSpot VM成为默认虚拟机
2. 目前**Hotspot占有绝对的市场地位称霸武林**。
* 不管是现在仍在广泛使用的JDK6还是使用比例较多的JDK8中默认的虚拟机都是HotSpot
* Sun/oracle JDK和openJDK的默认虚拟机
* 因此本课程中默认介绍的虚拟机都是HotSpot相关机制也主要是指HotSpot的GC机制。比如其他两个商用虚机都没有方法区的概念
3. 从服务器、桌面到移动端、嵌入式都有应用。
4. 名称中的HotSpot指的就是它的热点代码探测技术。
* 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
* 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡
### JRockit商用三大虚拟机之一
1. 专注于服务器端应用它可以不太关注程序启动速度因此JRockit内部不包含解析器实现全部代码都靠即时编译器编译后执行。
2. 大量的行业基准测试显示JRockit JVM是世界上最快的JVM使用JRockit产品客户已经体验到了显著的性能提高一些超过了70%和硬件成本的减少达50%)。
3. 优势全面的Java运行时解决方案组合
* JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微秒级的JVM响应时间适合财务、军事指挥、电信网络的需要
* Mission Control服务套件它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
4. 2008年JRockit被Oracle收购。
5. Oracle表达了整合两大优秀虚拟机的工作大致在JDK8中完成。整合的方式是在HotSpot的基础上移植JRockit的优秀特性。
6. 高斯林:目前就职于谷歌,研究人工智能和水下机器人
### IBM的J9商用三大虚拟机之一
1. 全称IBM Technology for Java Virtual Machine简称IT4J内部代号J9
2. 市场定位与HotSpot接近服务器端、桌面应用、嵌入式等多用途VM广泛用于IBM的各种Java产品。
3. 目前有影响力的三大商用虚拟机之一也号称是世界上最快的Java虚拟机。
4. 2017年左右IBM发布了开源J9VM命名为openJ9交给Eclipse基金会管理也称为Eclipse OpenJ9
5. OpenJDK -> 是JDK开源了包括了虚拟机
### KVM和CDC/CLDC Hotspot
1. Oracle在Java ME产品线上的两款虚拟机为CDC/CLDC HotSpot Implementation VM
2. KVMKilobyte是CLDC-HI早期产品
3. 目前移动领域地位尴尬智能机被Android和iOS二分天下。
4. KVM简单、轻量、高度可移植面向更低端的设备上还维持自己的一片市场
* 智能控制器、传感器
* 老人手机、经济欠发达地区的功能手机
5. 所有的虚拟机的原则:一次编译,到处运行。
### Azul VM
1. 前面三大“高性能Java虚拟机”使用在**通用硬件平台上**
2. 这里Azul VW和BEA Liquid VM是与**特定硬件平台绑定**、软硬件配合的专有虚拟机高性能Java虚拟机中的战斗机。
3. Azul VM是Azul Systems公司在HotSpot基础上进行大量改进运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机。
4. 每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、专有硬件优化的线程调度等优秀特性。
5. 2010年Azul Systems公司开始从硬件转向软件发布了自己的Zing JVM可以在通用x86平台上提供接近于Vega系统的特性。
### Liquid VM
1. 高性能Java虚拟机中的战斗机。
2. BEA公司开发的直接运行在自家Hypervisor系统上
3. Liquid VM即是现在的JRockit VEVirtual Edition。**Liquid VM不需要操作系统的支持或者说它自己本身实现了一个专用操作系统的必要功能如线程调度、文件系统、网络支持等**。
5. 随着JRockit虚拟机终止开发Liquid vM项目也停止了。
### Apache Marmony
1. Apache也曾经推出过与JDK1.5和JDK1.6兼容的Java运行平台Apache Harmony。
2. 它是IElf和Intel联合开发的开源JVM受到同样开源的Open JDK的压制Sun坚决不让Harmony获得JCP认证最终于2011年退役IBM转而参与OpenJDK
3. 虽然目前并没有Apache Harmony被大规模商用的案例但是它的Java类库代码吸纳进了Android SDK。
### Micorsoft JVM
1. 微软为了在IE3浏览器中支持Java Applets开发了Microsoft JVM。
2. 只能在window平台下运行。但确是当时Windows下性能最好的Java VM。
3. 1997年Sun以侵犯商标、不正当竞争罪名指控微软成功赔了Sun很多钱。微软WindowsXP SP3中抹掉了其VM。现在Windows上安装的jdk都是HotSpot。
### Taobao JVM
1. 由AliJVM团队发布。阿里国内使用Java最强大的公司覆盖云计算、金融、物流、电商等众多领域需要解决高并发、高可用、分布式的复合问题。有大量的开源产品。
2. **基于OpenJDK开发了自己的定制版本AlibabaJDK**简称AJDK。是整个阿里Java体系的基石。
3. 基于OpenJDK Hotspot VM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机。
* 创新的GCIHGCinvisible heap技术实现了off-heap**即将生命周期较长的Java对象从heap中移到heap之外并且GC不能管理GCIH内部的Java对象以此达到降低GC的回收频率和提升GC的回收效率的目的**。
* GCIH中的**对象还能够在多个Java虚拟机进程中实现共享**
* 使用crc32指令实现JvM intrinsic降低JNI的调用开销
* PMU hardware的Java profiling tool和诊断协助功能
* 针对大数据场景的ZenGC
4. taobao vm应用在阿里产品上性能高**硬件严重依赖inte1的cpu损失了兼容性但提高了性能**
- 目前已经在淘宝、天猫上线把Oracle官方JvM版本全部替换了。
### Dalvik VM
1. 谷歌开发的应用于Android系统并在Android2.2中提供了JIT发展迅猛。
2. **Dalvik VM只能称作虚拟机而不能称作“Java虚拟机”**,它没有遵循 Java虚拟机规范
3. 不能直接执行Java的Class文件
4. 基于寄存器架构不是jvm的栈架构。
5. 执行的是编译以后的dexDalvik Executable文件。执行效率比较高。
- 它执行的dexDalvik Executable文件可以通过class文件转化而来使用Java语法编写应用程序可以直接使用大部分的Java API等。
7. Android 5.0使用支持提前编译Ahead of Time CompilationAoT的ART VM替换Dalvik VM。
### Graal VM未来虚拟机
1. 2018年4月Oracle Labs公开了GraalvM号称 “**Run Programs Faster Anywhere**”勃勃野心。与1995年java的”write oncerun anywhere"遥相呼应。
2. GraalVM在HotSpot VM基础上增强而成的**跨语言全栈虚拟机,可以作为“任何语言”**的运行平台使用。语言包括Java、Scala、Groovy、KotlinC、C++、Javascript、Ruby、Python、R等
3. 支持不同语言中混用对方的接口和对象,支持这些语言使用已经编写好的本地库文件
4. 工作原理是将这些语言的源代码或源代码编译后的中间格式通过解释器转换为能被Graal VM接受的中间表示。Graal VM提供Truffle工具集快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化获得比原生编译器更优秀的执行效率。
5. **如果说HotSpot有一天真的被取代Graalvm希望最大**。但是Java的软件生态没有丝毫变化。
### 总结
具体JVM的内存结构其实取决于其实现不同厂商的JVM或者同一厂商发布的不同版本都有可能存在一定差异。主要以Oracle HotSpot VM为默认虚拟机。

View File

@@ -1,817 +0,0 @@
---
title: JVM系列-第2章-类加载子系统
tags:
- JVM
- 虚拟机
categories:
- JVM
- 1.内存与垃圾回收篇
keywords: JVM虚拟机。
description: JVM系列-第2章-类加载子系统。
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/logo.png'
abbrlink: 2e0079af
date: 2020-11-02 21:31:58
---
第2章-类加载子系统
============
内存结构概述
--------
### 简图
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0001.png">
### 详细图
英文版
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0002.jpg">
中文版
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0003.jpg">
注意方法区只有HotSpot虚拟机有J9JRockit都没有
如果自己想手写一个Java虚拟机的话主要考虑哪些结构呢
1. 类加载器
2. 执行引擎
类加载器子系统
--------
**类加载器子系统作用:**
1. 类加载器子系统负责从文件系统或者网络中加载Class文件class文件在文件开头有特定的文件标识。
2. ClassLoader只负责class文件的加载至于它是否可以运行则由Execution Engine决定。
3. **加载的类信息存放于一块称为方法区的内存空间**。除了类的信息外方法区中还会存放运行时常量池信息可能还包括字符串字面量和数字常量这部分常量信息是Class文件中常量池部分的内存映射
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0004.png">
## 类加载器ClassLoader角色
1. class file在下图中就是Car.class文件存在于本地硬盘上可以理解为设计师画在纸上的模板而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
2. class file加载到JVM中被称为DNA元数据模板在下图中就是内存中的Car Class放在方法区。
3. 在.class文件>JVM>最终成为元数据模板此过程就要一个运输工具类装载器Class Loader扮演一个快递员的角色。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0005.png">
类加载过程
-------
### 概述
```java
public class HelloLoader {
public static void main(String[] args) {
System.out.println("谢谢ClassLoader加载我....");
System.out.println("你的大恩大德,我下辈子再报!");
}
}
```
它的加载过程是怎么样的呢?
* 执行 main() 方法静态方法就需要先加载main方法所在类 HelloLoader
* 加载成功,则进行链接、初始化等操作。完成后调用 HelloLoader 类中的静态方法 main
* 加载失败则抛出异常
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0006.png">
完整的流程图如下所示:
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0007.png">
### 加载阶段
**加载:**
1. 通过一个类的全限定名获取定义此类的二进制字节流
2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3. **在内存中生成一个代表这个类的java.lang.Class对象**,作为方法区这个类的各种数据的访问入口
**加载class文件的方式**
1. 从本地系统中直接加载
2. 通过网络获取典型场景Web Applet
3. 从zip压缩包中读取成为日后jar、war格式的基础
4. 运行时计算生成,使用最多的是:动态代理技术
5. 由其他文件生成典型场景JSP应用从专有数据库中提取.class文件比较少见
6. 从加密文件中获取典型的防Class文件被反编译的保护措施
### 链接阶段
链接分为三个子阶段:验证 -> 准备 -> 解析
#### 验证(Verify)
1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求保证被加载类的正确性不会危害虚拟机自身安全
2. 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
**举例**
使用 BinaryViewer软件查看字节码文件其开头均为 CAFE BABE ,如果出现不合法的字节码文件,那么将会验证不通过。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0008.png">
#### 准备(Prepare)
1. 为类变量static变量分配内存并且设置该类变量的默认初始值即零值
2. 这里不包含用final修饰的static因为final在编译的时候就会分配好了默认值准备阶段会显式初始化
3. 注意这里不会为实例变量分配初始化类变量会分配在方法区中而实例变量是会随着对象一起分配到Java堆中
**举例**
代码变量a在准备阶段会赋初始值但不是1而是0在初始化阶段会被赋值为 1
```java
public class HelloApp {
private static int a = 1;//preparea = 0 ---> initial : a = 1
public static void main(String[] args) {
System.out.println(a);
}
}
```
#### 解析(Resolve)
1. **将常量池内的符号引用转换为直接引用的过程**
2. 事实上解析操作往往会伴随着JVM在执行完初始化之后再执行
3. 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
4. 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等
**符号引用**
* 反编译 class 文件后可以查看符号引用,下面带# 的就是符号引用
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0023.png"/>
### 初始化阶段
#### 类的初始化时机
1. 创建类的实例
2. 访问某个类或接口的静态变量,或者对该静态变量赋值
3. 调用类的静态方法
4. 反射比如Class.forName(“com.atguigu.Test”)
5. 初始化一个类的子类
6. Java虚拟机启动时被标明为启动类的类
7. JDK7开始提供的动态语言支持java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化则初始化
除了以上七种情况其他使用Java类的方式都被看作是对类的被动使用都不会导致类的初始化即不会执行初始化阶段不会调用 clinit() 方法和 init() 方法)
### clinit()
1. 初始化阶段就是执行类构造器方法`<clinit>()`的过程
2. 此方法不需定义是javac编译器自动收集类中的所有**类变量**的赋值动作和静态代码块中的语句合并而来。也就是说当我们代码中包含static变量的时候就会有clinit方法
3. `<clinit>()`方法中的指令按语句在源文件中出现的顺序执行
4. `<clinit>()`不同于类的构造器。(关联:构造器是虚拟机视角下的`<init>()`
5. 若该类具有父类JVM会保证子类的`<clinit>()`执行前,父类的`<clinit>()`已经执行完毕
6. 虚拟机必须保证一个类的`<clinit>()`方法在多线程下被同步加锁
> IDEA 中安装 JClassLib Bytecode viewer 插件,可以很方便的看字节码。安装过程可以自行百度
#### 123说明
**举例1有static变量**
查看下面这个代码的字节码,可以发现有一个`<clinit>()`方法。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0009.png">
```java
public class ClassInitTest {
private static int num = 1;
static{
num = 2;
number = 20;
System.out.println(num);
//System.out.println(number);//报错:非法的前向引用。
}
/**
* 1、linking之prepare: number = 0 --> initial: 20 --> 10
* 2、这里因为静态代码块出现在声明变量语句前面所以之前被准备阶段为0的number变量会
* 首先被初始化为20再接着被初始化成10这也是面试时常考的问题哦
*
*/
private static int number = 10;
public static void main(String[] args) {
System.out.println(ClassInitTest.num);//2
System.out.println(ClassInitTest.number);//10
}
}
```
<clint字节码>
```java
0 iconst_1
1 putstatic #3 <com/atguigu/java/ClassInitTest.num>
4 iconst_2
5 putstatic #3 <com/atguigu/java/ClassInitTest.num>
8 bipush 20 //先赋20
10 putstatic #5 <com/atguigu/java/ClassInitTest.number>
13 getstatic #2 <java/lang/System.out>
16 getstatic #3 <com/atguigu/java/ClassInitTest.num>
19 invokevirtual #4 <java/io/PrintStream.println>
22 bipush 10 //再赋10
24 putstatic #5 <com/atguigu/java/ClassInitTest.number>
27 return
```
当我们代码中包含static变量的时候就会有clinit方法
**举例2无 static 变量**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0010.png">
加上之后就有了
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0011.png">
#### 4说明
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0012.png">
在构造器中:
* 先将类变量 a 赋值为 10
* 再将局部变量赋值为 20
#### 5说明
若该类具有父类JVM会保证子类的`<clinit>()`执行前,父类的`<clinit>()`已经执行完毕
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0013.png">
如上代码,加载流程如下:
* 首先,执行 main() 方法需要加载 ClinitTest1 类
* 获取 Son.B 静态变量,需要加载 Son 类
* Son 类的父类是 Father 类,所以需要先执行 Father 类的加载,再执行 Son 类的加载
#### 6说明
虚拟机必须保证一个类的`<clinit>()`方法在多线程下被同步加锁
```java
public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DeadThread dead = new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread t1 = new Thread(r,"线程1");
Thread t2 = new Thread(r,"线程2");
t1.start();
t2.start();
}
}
class DeadThread{
static{
if(true){
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true){
}
}
}
}
```
输出结果:
```
线程2开始
线程1开始
线程2初始化当前类
/然后程序卡死了
```
程序卡死,分析原因:
* 两个线程同时去加载 DeadThread 类,而 DeadThread 类中静态代码块中有一处死循环
* 先加载 DeadThread 类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,而另一个线程在等待同步锁的释放
* 所以无论哪个线程先执行 DeadThread 类的加载,另外一个类也不会继续执行。(一个类只会被加载一次)
类加载器的分类
---------
### 概述
1. JVM严格来讲支持两种类型的类加载器 。分别为引导类加载器Bootstrap ClassLoader和自定义类加载器User-Defined ClassLoader
2. 从概念上来讲自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器但是Java虚拟机规范却没有这么定义而是**将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器**
3. 无论类加载器的类型如何划分在程序中我们最常见的类加载器始终只有3个如下所示
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0014.png">
**ExtClassLoader**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0015.png">
**AppClassLoader**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0016.png">
```java
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
```
* 我们尝试获取引导类加载器,获取到的值为 null ,这并不代表引导类加载器不存在,**因为引导类加载器右 C/C++ 语言,我们获取不到**
* 两次获取系统类加载器的值都相同sun.misc.Launcher$AppClassLoader@18b4aac2 ,这说明**系统类加载器是全局唯一的**
### 虚拟机自带的加载器
#### 启动类加载器
> **启动类加载器引导类加载器Bootstrap ClassLoader**
1. 这个类加载使用C/C++语言实现的嵌套在JVM内部
2. 它用来加载Java的核心库JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容用于提供JVM自身需要的类
3. 并不继承自java.lang.ClassLoader没有父加载器
4. 加载扩展类和应用程序类加载器,并作为他们的父类加载器
5. 出于安全考虑Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
#### 扩展类加载器
> **扩展类加载器Extension ClassLoader**
1. Java语言编写由sun.misc.Launcher$ExtClassLoader实现
2. 派生于ClassLoader类
3. 父类加载器为启动类加载器
4. 从java.ext.dirs系统属性所指定的目录中加载类库或从JDK的安装目录的jre/lib/ext子目录扩展目录下加载类库。如果用户创建的JAR放在此目录下也会自动由扩展类加载器加载
#### 系统类加载器
> **应用程序类加载器也称为系统类加载器AppClassLoader**
1. Java语言编写由sun.misc.LaunchersAppClassLoader实现
2. 派生于ClassLoader类
3. 父类加载器为扩展类加载器
4. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
5. 该类加载是程序中默认的类加载器一般来说Java应用的类都是由它来完成加载
6. 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器
```java
public class ClassLoaderTest1 {
public static void main(String[] args) {
System.out.println("**********启动类加载器**************");
//获取BootstrapClassLoader能够加载的api的路径
URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL element : urLs) {
System.out.println(element.toExternalForm());
}
//从上面的路径中随意选择一个类,来看看他的类加载器是什么:引导类加载器
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);
System.out.println("***********扩展类加载器*************");
String extDirs = System.getProperty("java.ext.dirs");
for (String path : extDirs.split(";")) {
System.out.println(path);
}
//从上面的路径中随意选择一个类,来看看他的类加载器是什么:扩展类加载器
ClassLoader classLoader1 = CurveDB.class.getClassLoader();
System.out.println(classLoader1);//sun.misc.Launcher$ExtClassLoader@1540e19d
}
}
```
**输出结果**
```java
**********启动类加载器**************
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/classes
null
***********扩展类加载器*************
C:\Program Files\Java\jdk1.8.0_131\jre\lib\ext
C:\Windows\Sun\Java\lib\ext
sun.misc.Launcher$ExtClassLoader@29453f44
```
### 用户自定义类加载器
#### 什么时候需要自定义类加载器?
在Java的日常应用程序开发中类的加载几乎是由上述3种类加载器相互配合执行的在必要时我们还可以自定义类加载器来定制类的加载方式。那为什么还需要自定义类加载器
1. 隔离加载类比如说我假设现在Spring框架和RocketMQ有包名路径完全一样的类类名也一样这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器实现不同的框架中间价之间是隔离的
2. 修改类加载的方式
3. 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
4. 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)
#### 如何自定义类加载器?
1. 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式实现自己的类加载器以满足一些特殊的需求
2. 在JDK1.2之前在自定义类加载器时总会去继承ClassLoader类并重写loadClass()方法从而实现自定义的类加载类但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法而是建议把自定义的类加载逻辑写在findclass()方法中
3. 在编写自定义类加载器时如果没有太过于复杂的需求可以直接继承URIClassLoader类这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
**代码示例**
```java
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] result = getClassFromCustomPath(name);
if (result == null) {
throw new FileNotFoundException();
} else {
//defineClass和findClass搭配使用
return defineClass(name, result, 0, result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
//自定义流的获取方式
private byte[] getClassFromCustomPath(String name) {
//从自定义路径中加载指定类:细节略
//如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
return null;
}
public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One", true, customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}
```
### 关于ClassLoader
> **ClassLoader 类介绍**
ClassLoader类它是一个抽象类其后所有的类加载器都继承自ClassLoader不包括启动类加载器
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0017.png">
sun.misc.Launcher 它是一个java虚拟机的入口应用
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0018.png">
#### 获取ClassLoader途径
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0019.png">
```java
public class ClassLoaderTest2 {
public static void main(String[] args) {
try {
//1.
ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
System.out.println(classLoader);
//2.
ClassLoader classLoader1 = Thread.currentThread().getContextClassLoader();
System.out.println(classLoader1);
//3.
ClassLoader classLoader2 = ClassLoader.getSystemClassLoader().getParent();
System.out.println(classLoader2);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
```
输出结果:
```
null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d
Process finished with exit code 0
```
双亲委派机制
--------
### 双亲委派机制原理
Java虚拟机对class文件采用的是**按需加载**的方式也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时Java虚拟机采用的是双亲委派模式即把请求交由父类处理它是一种任务委派模式
1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
4. 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0020.png">
### 双亲委派机制代码演示
#### 举例1
1、我们自己建立一个 java.lang.String 类,写上 static 代码块
```java
public class String {
//
static{
System.out.println("我是自定义的String类的静态代码块");
}
}
```
2、在另外的程序中加载 String 类,看看加载的 String 类是 JDK 自带的 String 类,还是我们自己编写的 String 类
```java
public class StringTest {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
System.out.println("hello,atguigu.com");
StringTest test = new StringTest();
System.out.println(test.getClass().getClassLoader());
}
}
```
输出结果:
```
hello,atguigu.com
sun.misc.Launcher$AppClassLoader@18b4aac2
```
程序并没有输出我们静态代码块中的内容,可见仍然加载的是 JDK 自带的 String 类。
把刚刚的类改一下
```java
package java.lang;
public class String {
//
static{
System.out.println("我是自定义的String类的静态代码块");
}
//错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}
```
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0021.png">
由于双亲委派机制一直找父类所以最后找到了Bootstrap ClassLoaderBootstrap ClassLoader找到的是 JDK 自带的 String 类在那个String类中并没有 main() 方法,所以就报了上面的错误。
#### 举例2
```java
package java.lang;
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}
```
输出结果:
```java
java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main"
Process finished with exit code 1
```
即使类名没有重复也禁止使用java.lang这种包名。这是一种保护机制
#### 举例3
当我们加载jdbc.jar 用于实现数据库连接的时候
1. 我们现在程序中需要用到SPI接口而SPI接口属于rt.jar包中Java核心api
2. 然后使用双清委派机制引导类加载器把rt.jar包加载进来而rt.jar包中的SPI存在一些接口接口我们就需要具体的实现类了
3. 具体的实现类就涉及到了某些第三方的jar包了比如我们加载SPI的实现类jdbc.jar包【首先我们需要知道的是 jdbc.jar是基于SPI接口进行实现的】
4. 第三方的jar包中的类属于系统类加载器来加载
5. 从这里面就可以看到SPI核心接口由引导类加载器来加载SPI具体实现类由系统类加载器来加载
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_002/0022.png">
### 双亲委派机制优势
通过上面的例子,我们可以知道,双亲机制可以
1. 避免类的重复加载
2. 保护程序安全防止核心API被随意篡改
- 自定义类自定义java.lang.String 没有被加载。
- 自定义类java.lang.ShkStart报错阻止创建 java.lang开头的类
沙箱安全机制
--------
1. 自定义String类时在加载自定义String类的时候会率先使用引导类加载器加载而引导类加载器在加载的过程中会先加载jdk自带的文件rt.jar包中java.lang.String.class报错信息说没有main方法就是因为加载的是rt.jar包中的String类。
2. 这样可以保证对java核心源代码的保护这就是沙箱安全机制。
其他
----
### 如何判断两个class对象是否相同
在JVM中表示两个class对象是否为同一个类存在两个必要条件
1. 类的完整类名必须一致,包括包名
2. **加载这个类的ClassLoader指ClassLoader实例对象必须相同**
3. 换句话说在JVM中即使这两个类对象class对象来源同一个Class文件被同一个虚拟机所加载但只要加载它们的ClassLoader实例对象不同那么这两个类对象也是不相等的
### 对类加载器的引用
1. JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的
2. **如果一个类型是由用户类加载器加载的那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中**
3. 当解析一个类型到另一个类型的引用的时候JVM需要保证这两个类型的类加载器是相同的后面讲

View File

@@ -1,408 +0,0 @@
---
title: JVM系列-第3章-运行时数据区
tags:
- JVM
- 虚拟机
categories:
- JVM
- 1.内存与垃圾回收篇
keywords: JVM虚拟机。
description: JVM系列-第3章-运行时数据区。
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/logo.png'
abbrlink: a7ad3cab
date: 2020-11-09 15:38:42
---
> 此章把运行时数据区里比较少的地方讲一下。虚拟机栈,堆,方法区这些地方后续再讲。
运行时数据区概述及线程
=================
前言
----
本节主要讲的是运行时数据区,也就是下图这部分,它是在类加载完成后的阶段
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0001.png">
当我们通过前面的:类的加载 --> 验证 --> 准备 --> 解析 --\> 初始化,这几个阶段完成后,就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0002.png">
类比一下也就是大厨做饭,我们把大厨后面的东西(切好的菜,刀,调料),比作是运行时数据区。而厨师可以类比于执行引擎,将通过准备的东西进行制作成精美的菜品。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0003.png">
运行时数据区结构
----------
### 运行时数据区与内存
1. 内存是非常重要的系统资源是硬盘和CPU的中间仓库及桥梁承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略保证了JVM的高效稳定运行。**不同的JVM对于内存的划分方式和管理机制存在着部分差异**。结合JVM虚拟机规范来探讨一下经典的JVM内存布局。
2. 我们通过磁盘或者网络IO得到的数据都需要先加载到内存中然后CPU从内存中获取数据进行读取也就是说内存充当了CPU和磁盘之间的桥梁
> 下图来自阿里巴巴手册JDK8
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0004.jpg">
### 线程的内存空间
1. Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区其中有一些会随着虚拟机启动而创建随着虚拟机退出而销毁。另外一些则是与线程一一对应的这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
2. 灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 线程独有:独立包括程序计数器、栈、本地方法栈
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0005.png">
### Runtime类
**每个JVM只有一个Runtime实例**。即为运行时环境,相当于内存结构的中间的那个框框:运行时环境。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0006.png">
线程
----
### JVM 线程
1. 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
2. **在Hotspot JVM里每个线程都与操作系统的本地线程直接映射**
- 当一个Java线程准备好执行以后此时一个操作系统的本地线程也同时创建。Java线程执行终止后本地线程也会回收
4. 操作系统负责将线程安排调度到任何一个可用的CPU上。一旦本地线程初始化成功它就会调用Java线程中的run()方法
> 关于线程并发可以看笔者的Java并发系列
### JVM 系统线程
- 如果你使用jconsole或者是任何一个调试工具都能看到在后台有许多线程在运行。这些后台线程不包括调用`public static void main(String[])`的main线程以及所有这个main线程自己创建的线程。
- 这些主要的后台系统线程在Hotspot JVM里主要是以下几个
1. **虚拟机线程**这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点这样堆才不会变化。这种线程的执行类型括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
2. **周期任务线程**:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
3. **GC线程**这种线程对在JVM里不同种类的垃圾收集行为提供了支持
4. **编译线程**:这种线程在运行时会将字节码编译成到本地代码
5. **信号调度线程**这种线程接收信号并发送给JVM在它内部通过调用适当的方法进行处理
程序计数器(PC寄存器)
===========
PC寄存器介绍
----------
> 官方文档网址https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0007.png">
1. JVM中的程序计数寄存器Program Counter RegisterRegister的命名源于CPU的寄存器**寄存器存储指令相关的现场信息**。CPU只有把数据装载到寄存器才能够运行。
2. 这里并非是广义上所指的物理寄存器或许将其翻译为PC计数器或指令计数器会更加贴切也称为程序钩子并且也不容易引起一些不必要的误会。**JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟**。
3. 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
4. 在JVM规范中每个线程都有它自己的程序计数器是线程私有的生命周期与线程的生命周期保持一致。
5. 任何时间一个线程都只有一个方法在执行,也就是所谓的**当前方法**。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址或者如果是在执行native方法则是未指定值undefned
6. 它是**程序控制流**的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
7. 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
8. 它是**唯一一个**在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。
## PC寄存器的作用
PC寄存器用来存储指向下一条指令的地址也即将要执行的指令代码。由执行引擎读取下一条指令并执行该指令。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0008.png">
举例
------
```java
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
String s = "abc";
System.out.println(i);
System.out.println(k);
}
}
```
查看字节码
> 看字节码的方法https://blog.csdn.net/21aspnet/article/details/88351875
```java
Classfile /F:/IDEAWorkSpaceSourceCode/JVMDemo/out/production/chapter04/com/atguigu/java/PCRegisterTest.class
Last modified 2020-11-2; size 675 bytes
MD5 checksum 53b3ef104479ec9e9b7ce5319e5881d3
Compiled from "PCRegisterTest.java"
public class com.atguigu.java.PCRegisterTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#26 // java/lang/Object."<init>":()V
#2 = String #27 // abc
#3 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#5 = Class #32 // com/atguigu/java/PCRegisterTest
#6 = Class #33 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/atguigu/java/PCRegisterTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 i
#19 = Utf8 I
#20 = Utf8 j
#21 = Utf8 k
#22 = Utf8 s
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 SourceFile
#25 = Utf8 PCRegisterTest.java
#26 = NameAndType #7:#8 // "<init>":()V
#27 = Utf8 abc
#28 = Class #34 // java/lang/System
#29 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 com/atguigu/java/PCRegisterTest
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public com.atguigu.java.PCRegisterTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/atguigu/java/PCRegisterTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: ldc #2 // String abc
12: astore 4
14: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
17: iload_1
18: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
21: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_3
25: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
28: return
LineNumberTable:
line 10: 0
line 11: 3
line 12: 6
line 14: 10
line 15: 14
line 16: 21
line 18: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 args [Ljava/lang/String;
3 26 1 i I
6 23 2 j I
10 19 3 k I
14 15 4 s Ljava/lang/String;
}
SourceFile: "PCRegisterTest.java"
```
* 左边的数字代表**指令地址(指令偏移)**,即 PC 寄存器中可能存储的值,然后执行引擎读取 PC 寄存器中的值,并执行该指令
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0009.png">
两个面试题
-------
**使用PC寄存器存储字节码指令地址有什么用呢**或者问**为什么使用 PC 寄存器来记录当前线程的执行地址呢?**
1. 因为CPU需要不停的切换各个线程这时候切换回来以后就得知道接着从哪开始继续执行
2. JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0010.png">
**PC寄存器为什么被设定为私有的**
1. 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法CPU会不停地做任务切换这样必然导致经常中断或恢复如何保证分毫无差呢**为了能够准确地记录各个线程正在执行的当前字节码指令地址最好的办法自然是为每一个线程都分配一个PC寄存器**,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
2. 由于CPU时间片轮限制众多线程在并发执行过程中任何一个确定的时刻一个处理器或者多核处理器中的一个内核只会执行某个线程中的一条指令。
3. 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
> 注意并行和并发的区别,笔者的并发系列有讲
CPU 时间片
---------
1. CPU时间片即CPU分配给各个程序的时间每个线程被分配一个时间段称作它的时间片。
2. 在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
3. 但在微观上由于只有一个CPU一次只能处理程序要求的一部分如何处理公平一种方法就是引入时间片**每个程序轮流执行**。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0011.png">
# 本地方法接口
## 本地方法
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0012.png">
1. 简单地讲,**一个Native Method是一个Java调用非Java代码的接囗**一个Native Method是这样一个Java方法该方法的实现由非Java语言实现比如C。这个特征并非Java所特有很多其它的编程语言都有这一机制比如在C++中你可以用extern 告知C++编译器去调用一个C的函数。
4. “A native method is a Java method whose implementation is provided by non-java code.”本地方法是一个非Java的方法它的具体实现是非Java代码的实现
5. 在定义一个native method时并不提供实现体有些像定义一个Java interface因为其实现体是由非java语言在外面实现的。
6. 本地接口的作用是融合不同的编程语言为Java所用它的初衷是融合C/C++程序。
## 举例
需要注意的是标识符native可以与其它java标识符连用但是abstract除外
```java
public class IHaveNatives {
public native void Native1(int x);
public native static long Native2();
private native synchronized float Native3(Object o);
native void Native4(int[] ary) throws Exception;
}
```
## 为什么要使用 Native Method
Java使用起来非常方便然而有些层次的任务用Java实现起来不容易或者我们对程序的效率很在意时问题就来了。
### 与Java环境外交互
**有时Java应用需要与Java外面的硬件环境交互这是本地方法存在的主要原因**。你可以想想Java需要与一些**底层系统**如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制它为我们提供了一个非常简洁的接口而且我们无需去了解Java应用之外的繁琐的细节。
### 与操作系统的交互
1. JVM支持着Java语言本身和运行时库它是Java程序赖以生存的平台它由一个解释器解释字节码和一些连接到本地代码的库组成。
2. 然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。
3. **通过使用本地方法我们得以用Java实现了jre的与底层系统的交互甚至JVM的一些部分就是用C写的**
4. 还有如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时我们也需要使用本地方法。
### Suns Java
1. Sun的解释器是用C实现的这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的它也通过一些本地方法与外界交互。
2. 例如类java.lang.Thread的setPriority()方法是用Java实现的但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的并被植入JVM内部在Windows 95的平台上这个本地方法最终将调用Win32 setpriority() API。这是一个本地方法的具体实现由JVM直接提供更多的情况是本地方法由外部的动态链接库external dynamic link library提供然后被JVM调用。
### 本地方法的现状
目前该方法使用的越来越少了除非是与硬件有关的应用比如通过Java程序驱动打印机或者Java系统管理生产设备在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达比如可以使用Socket通信也可以使用Web Service等等不多做介绍。
# 本地方法栈
1. **Java虚拟机栈于管理Java方法的调用而本地方法栈用于管理本地方法的调用**
2. 本地方法栈,也是线程私有的。
3. 允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面和虚拟机栈相同)
* 如果线程请求分配的栈容量超过本地方法栈允许的最大容量Java虚拟机将会抛出一个stackoverflowError 异常。
* 如果本地方法栈可以动态扩展并且在尝试扩展的时候无法申请到足够的内存或者在创建新的线程时没有足够的内存去创建对应的本地方法栈那么Java虚拟机将会抛出一个outofMemoryError异常。
4. 本地方法一般是使用C语言或C++语言实现的。
5. 它的具体做法是Native Method Stack中登记native方法在Execution Engine 执行时加载本地方法库。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/chapter_003/0013.png">
**注意事项**
1. 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
* 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
* 它甚至可以直接使用本地处理器中的寄存器
* 直接从本地内存的堆中分配任意数量的内存
2. 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法也可以无需实现本地方法栈。
3. 在Hotspot JVM中直接将本地方法栈和虚拟机栈合二为一。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
---
title: JVM系列-第7章-对象的实例化内存布局与访问定位
tags:
- JVM
- 虚拟机
categories:
- JVM
- 1.内存与垃圾回收篇
keywords: JVM虚拟机。
description: JVM系列-第7章-对象的实例化内存布局与访问定位。
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/logo.png'
abbrlink: debff71a
date: 2020-11-14 19:38:42
---
对象的实例化内存布局与访问定位
======================
对象的实例化
--------
**大厂面试题**
美团:
1. 对象在`JVM`中是怎么存储的?
2. 对象头信息里面有哪些东西?
蚂蚁金服:
二面:`java`对象头里有什么
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0001.png">
### 对象创建的方式
1. new最常见的方式、单例类中调用getInstance的静态类方法XXXFactory的静态方法
2. Class的newInstance方法在JDK9里面被标记为过时的方法因为只能调用空参构造器并且权限必须为 public
3. Constructor的newInstance(Xxxx):反射的方式,可以调用空参的,或者带参的构造器
4. 使用clone()不调用任何的构造器要求当前的类需要实现Cloneable接口中的clone方法
5. 使用序列化从文件中从网络中获取一个对象的二进制流序列化一般用于Socket的网络传输
6. 第三方库 Objenesis
### 对象创建的步骤
> **从字节码看待对象的创建过程**
```java
public class ObjectTest {
public static void main(String[] args) {
Object obj = new Object();
}
}
```
```
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: return
LineNumberTable:
line 9: 0
line 10: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
8 1 1 obj Ljava/lang/Object;
}
```
**1、判断对象对应的类是否加载、链接、初始化**
1. 虚拟机遇到一条new指令首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用并且检查这个符号引用代表的类是否已经被加载解析和初始化。即判断类元信息是否存在
2. 如果该类没有加载那么在双亲委派模式下使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的.class文件如果没有找到文件则抛出ClassNotFoundException异常如果找到则进行类加载并生成对应的Class对象。
**2、为对象分配内存**
1. 首先计算对象占用空间的大小接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量仅分配引用变量空间即可即4个字节大小
2. 如果内存规整:采用指针碰撞分配内存
* 如果内存是规整的那么虚拟机将采用的是指针碰撞法Bump The Point来为对象分配内存。
* 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了。
* 如果垃圾收集器选择的是Serial ParNew这种基于压缩算法的虚拟机采用这种分配方式。一般使用带Compact整理过程的收集器时使用指针碰撞。
* 标记压缩(整理)算法会整理内存碎片,堆内存一存对象,另一边为空闲区域
3. 如果内存不规整
* 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。
* 意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表Free List
* 选择哪种分配方式由Java堆是否规整所决定而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
* 标记清除算法清理过后的堆内存,就会存在很多内存碎片。
**3、处理并发问题**
1. 采用CAS+失败重试保证更新的原子性
2. 每个线程预先分配TLAB - 通过设置 -XX:+UseTLAB参数来设置区域加锁机制
3. 在Eden区给每个线程分配一块区域
**4、初始化分配到的空间**
- 所有属性设置默认值,保证对象实例字段在不赋值可以直接使用
- 给对象属性赋值的顺序:
1. 属性的默认值初始化
2. 显示初始化/代码块初始化(并列关系,谁先谁后看代码编写的顺序)
3. 构造器初始化
**5、设置对象的对象头**
将对象的所属类即类的元数据信息、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
**6、执行init方法进行初始化**
1. 在Java程序的视角看来初始化才正式开始。初始化成员变量执行实例化代码块调用类的构造方法并把堆内对象的首地址赋值给引用变量
2. 因此一般来说由字节码中跟随invokespecial指令所决定new指令之后会接着就是执行init方法把对象按照程序员的意愿进行初始化这样一个真正可用的对象才算完成创建出来。
> **从字节码角度看 init 方法**
```java
/**
* 测试对象实例化的过程
* ① 加载类元信息 - ② 为对象分配内存 - ③ 处理并发问题 - ④ 属性的默认初始化(零值初始化)
* - ⑤ 设置对象头的信息 - ⑥ 属性的显式初始化、代码块中初始化、构造器中初始化
*
*
* 给对象的属性赋值的操作:
* ① 属性的默认初始化 - ② 显式初始化 / ③ 代码块中初始化 - ④ 构造器中初始化
*/
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
}
class Account{
}
```
**Customer类的字节码**
```java
0 aload_0
1 invokespecial #1 <java/lang/Object.<init>>
4 aload_0
5 sipush 1001
8 putfield #2 <com/atguigu/java/Customer.id>
11 aload_0
12 ldc #3 <匿名客户>
14 putfield #4 <com/atguigu/java/Customer.name>
17 aload_0
18 new #5 <com/atguigu/java/Account>
21 dup
22 invokespecial #6 <com/atguigu/java/Account.<init>>
25 putfield #7 <com/atguigu/java/Customer.acct>
28 return
```
* init() 方法的字节码指令:
* 属性的默认值初始化:`id = 1001;`
* 显示初始化/代码块初始化:`name = "匿名客户";`
* 构造器初始化:`acct = new Account();`
对象的内存布局
---------
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0002.png">
> **内存布局总结**
```java
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
public static void main(String[] args) {
Customer cust = new Customer();
}
}
class Account{
}
```
图解内存布局
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0003.png">
对象的访问定位
---------
**JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0004.png">
定位通过栈上reference访问
**对象的两种访问方式:句柄访问和直接指针**
**1、句柄访问**
1. 缺点:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低
2. 优点reference中存储稳定句柄地址对象被移动垃圾收集时移动对象很普遍时只会改变句柄中实例数据指针即可reference本身不需要被修改
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0005.png">
**2、直接指针HotSpot采用**
1. 优点:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
2. 缺点:对象被移动(垃圾收集时移动对象很普遍)时需要修改 reference 的值
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_007/0006.png">

View File

@@ -1,535 +0,0 @@
---
title: JVM系列-第8章-执行引擎
tags:
- JVM
- 虚拟机
categories:
- JVM
- 1.内存与垃圾回收篇
keywords: JVM虚拟机。
description: JVM系列-第8章-执行引擎。
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/JVM/logo.png'
abbrlink: 408712f4
date: 2020-11-15 19:48:42
---
执行引擎
===========
执行引擎概述
--------
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0001.png">
### 执行引擎概述
1. 执行引擎是Java虚拟机核心的组成部分之一。
2. “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而**虚拟机的执行引擎则是由软件自行实现的**,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,**能够执行那些不被硬件直接支持的指令集格式**。
3. JVM的主要任务是负责**装载字节码到其内部**但字节码并不能够直接运行在操作系统之上因为字节码指令并非等价于本地机器指令它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表以及其他辅助信息。
4. 那么如果想要让一个Java程序运行起来执行引擎Execution Engine的任务就是**将字节码指令解释/编译为对应平台上的本地机器指令才可以**。简单来说JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0002.png">
1、前端编译从Java程序员-字节码文件的这个过程叫前端编译
2、执行引擎这里有两种行为一种是解释执行一种是编译执行这里的是后端编译
### 执行引擎工作过程
> **执行引擎工作过程**
1. 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
2. 每当执行完一项指令操作后PC寄存器就会更新下一条需要被执行的指令地址。
3. 当然方法在执行的过程中执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息以及通过对象头中的元数据指针定位到目标对象的类型信息。
4. 从外观上来看所有的Java虚拟机的执行引擎输入、处理、输出都是一致的输入的是字节码二进制流处理过程是字节码解析执行、即时编译的等效过程输出的是执行过程。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0003.png">
Java代码编译和执行过程
----------------
### 解释执行和即时编译
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤:
1. 前面橙色部分是编译生成生成字节码文件的过程javac编译器来完成也就是前端编译器和JVM没有关系。
2. 后面绿色解释执行和蓝色即时编译才是JVM需要考虑的过程
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0004.png">
3. javac编译器前端编译器流程图如下所示
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0005.png">
4. Java字节码的执行是由JVM执行引擎来完成流程图如下所示
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0006.png">
### 什么是解释器什么是JIT编译器
1. 解释器当Java虚拟机启动时会根据预定义的规范对字节码采用**逐行**解释的方式**执行**,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
2. JITJust In Time Compiler编译器就是虚拟机将源代码**一次性直接**编译成和本地机器平台相关的机器语言,**但并不是马上执行**。
**为什么Java是半编译半解释型语言**
1. JDK1.0时代将Java语言定位为“解释执行”还是比较准确的。再后来Java也发展出可以直接生成本地代码的编译器。
2. 现在JVM在执行Java代码的时候通常都会将解释执行与编译执行二者结合起来进行。
3. JIT编译器将字节码翻译成本地代码后就可以做一个缓存操作存储在方法区的JIT 代码缓存中(执行效率更高了),并且在翻译成本地代码的过程中可以做优化。
**用图总结一下**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0007.png">
机器码 指令 汇编语言
-------------
### 机器码
1. 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
2. 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
3. 用它编写的程序一经输入计算机CPU直接读取运行因此和其他语言编的程序相比执行速度最快。
4. 机器指令与CPU紧密相关所以不同种类的CPU所对应的机器指令也就不同。
### 指令和指令集
**指令**
1. 由于机器码是由0和1组成的二进制序列可读性实在太差于是人们发明了指令。
2. 指令就是把机器码中特定的0和1序列简化成对应的指令一般为英文简写如movinc等可读性稍好
3. 由于不同的硬件平台执行同一个操作对应的机器码可能不同所以不同的硬件平台的同一种指令比如mov对应的机器码也可能不同。
**指令集**
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。如常见的
1. x86指令集对应的是x86架构的平台
2. ARM指令集对应的是ARM架构的平台
### 汇编语言
1. 由于指令的可读性还是太差,于是人们又发明了汇编语言。
2. 在汇编语言中用助记符Mnemonics代替机器指令的操作码用地址符号Symbol或标号Label代替指令或操作数的地址。
3. 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
4. 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译(汇编)成机器指令码,计算机才能识别和执行。
### 高级语言
1. 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
2. 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0008.png">
### 字节码
1. 字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
2. 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。
3. 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
4. 字节码典型的应用为Java bytecode
### C、C++源程序执行过程
**编译过程又可以分成两个阶段:编译和汇编。**
1. 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
2. 汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0009.png">
解释器
-----
### 为什么要有解释器
1. JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性因此避免采用静态编译的方式由高级语言直接生成本地机器指令从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法也就是产生了一个中间产品**字节码**)。
2. 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
3. 当一条字节码指令被解释执行完成后接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0010.png">
### 解释器的分类
1. 在Java的发展历史里一共有两套解释执行器即古老的**字节码解释器**、现在普遍使用的**模板解释器**。
* 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
* 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
2. 在HotSpot VM中解释器主要由Interpreter模块和Code模块构成。
* Interpreter模块实现了解释器的核心功能
* Code模块用于管理HotSpot VM在运行时生成的本地机器指令
### 解释器的现状
1. 由于解释器在设计和实现上非常简单因此除了Java语言之外还有许多高级语言同样也是基于解释器执行的比如Python、Perl、Ruby等。但是在今天基于解释器执行已经沦落为低效的代名词并且时常被一些C/C++程序员所调侃。
2. 为了解决这个问题JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行而是将整个函数体编译成为机器码每次函数执行时只执行编译后的机器码即可这种方式可以使执行效率大幅度提升。
3. 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
## JIT编译器
### Java 代码执行的分类
1. 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
2. 第二种是编译执行直接编译成机器码。现代虚拟机为了提高执行效率会使用即时编译技术JITJust In Time将方法编译成机器码后再执行
1. HotSpot VM是目前市面上高性能虚拟机的代表作之一。**它采用解释器与即时编译器并存的架构**。在Java虚拟机运行时解释器和即时编译器能够相互协作各自取长补短尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
3. 在今天Java程序的运行性能早已脱胎换骨已经达到了可以和C/C++ 程序一较高下的地步。
### 为啥我们还需要解释器呢?
1. 有些开发人员会感觉到诧异既然HotSpot VM中已经内置JIT编译器了那么为什么还需要再使用解释器来“拖累”程序的执行性能呢比如JRockit VM内部就不包含解释器字节码全部都依靠即时编译器编译后执行。
2. JRockit虚拟机是砍掉了解释器也就是只采及时编译器。那是因为呢JRockit只部署在服务器上一般已经有时间让他进行指令编译的过程了对于响应来说要求不高等及时编译器的编译完成后就会提供更好的性能
**首先明确两点:**
1. 当程序启动后,解释器可以马上发挥作用,**响应速度快**,省去编译的时间,立即执行。
2. 编译器要想发挥作用,把代码编译成本地代码,**需要一定的执行时间**,但编译为本地代码后,执行效率高。
**所以:**
1. 尽管JRockit VM中程序的执行性能会非常高效但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说启动时间并非是关注重点但对于那些看中启动时间的应用场景而言或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。
2. 在此模式下在Java虚拟器启动时解释器可以首先发挥作用而不必等待即时编译器全部编译完成后再执行这样可以省去许多不必要的编译时间。随着时间的推移编译器发挥作用把越来越多的代码编译成本地代码获得更高的执行效率。
3. 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”(后备方案)。
### 案例
- 当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
1. 注意解释执行与编译执行在线上环境微妙的辩证关系。**机器在热机状态(已经运行了一段时间叫热机状态)可以承受的负载要大于冷机状态(刚启动的时候叫冷机状态)**。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
2. 在生产环境发布过程中以分批的方式进行发布根据机器数量划分成多个批次每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例某程序员在发布平台进行分批发布在输入发布总批数时误填写成分为两批发布。如果是热机状态在正常情况下一半的机器可以勉强承载流量但由于刚启动的JVM均是解释执行还没有进行热点代码统计和JIT动态编译导致机器启动之后当前1/2发布成功的服务器马上全部宕机此故障说明了JIT的存在。—**阿里团队**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0011.png">
```java
public class JITTest {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add("让天下没有难学的技术");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
```
通过 JVisualVM 查看 JIT 编译器执行的编译次数
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0012.png">
### JIT编译器相关概念
1. Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程。
2. 也可能是指虚拟机的后端运行期编译器JIT编译器Just In Time Compiler把字节码转变成机器码的过程。
3. 还可能是指使用静态提前编译器AOT编译器Ahead of Time Compiler直接把.java文件编译成本地机器代码的过程。可能是后续发展的趋势
**典型的编译器:**
1. 前端编译器Sun的javac、Eclipse JDT中的增量式编译器ECJ
2. JIT编译器HotSpot VM的C1、C2编译器。
3. AOT 编译器GNU Compiler for the JavaGCJ、Excelsior JET。
### 热点代码及探测方式
1. 当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令则需要根据代码被调用**执行的频率**而定。
2. 关于那些需要被编译为本地代码的字节码,也被称之为**“热点代码”**JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出**深度优化**将其直接编译为对应平台的本地机器指令以此提升Java程序的执行性能。
3. 一个被多次调用的方法,或者是一-个方法体内部循环次数较多的循环体都可以被称之为“热点代码”因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中因此也被称之为栈上替换或简称为OSR (On StackReplacement)编译。
4. 一个方法究竟要被调用多少次或者一个循环体究竟需要执行多少次循环才可以达到这个标准必然需要一个明确的阈值JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
5. **目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测**
6. 采用基于计数器的热点探测HotSpot VM将会为每一个方法都建立2个不同类型的计数器分别为方法调用计数器Invocation Counter和回边计数器Back Edge Counter
1. 方法调用计数器用于统计方法的调用次数
2. 回边计数器则用于统计循环体执行的循环次数
#### 方法调用计数器
1. 这个计数器就用于统计方法被调用的次数它的默认阀值在Client模式下是1500次在Server模式下是10000次。超过这个阈值就会触发JIT编译。
2. 这个阀值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。
3. 当一个方法被调用时会先检查该方法是否存在被JIT编译过的版本
* 如果存在,则优先使用编译后的本地代码来执行
* 如果不存在已被编译过的版本则将此方法的调用计数器值加1然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。
* 如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
* 如果未超过阈值,则使用解释器对字节码文件解释执行
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0013.png">
#### 热度衰减
1. 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即**一段时间之内方法被调用的次数**。当超过一定的时间限度如果方法的调用次数仍然不足以让它提交给即时编译器编译那这个方法的调用计数器就会被减少一半这个过程称为方法调用计数器热度的衰减Counter Decay而这段时间就称为此方法统计的半衰周期Counter Half Life Time半衰周期是化学中的概念比如出土的文物通过查看C60来获得文物的年龄
2. 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样的话,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
6. 另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间单位是秒。
#### 回边计数器
它的作用是统计一个方法中循环体代码执行的次数在字节码中遇到控制流向后跳转的指令称为“回边”Back Edge。显然建立回边计数器统计的目的就是为了触发OSR编译。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0014.png">
### HotSpotVM可以设置程序执行方法
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构当然开发人员可以根据具体的应用场景通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行还是完全采用即时编译器执行。如下所示
1. -Xint完全采用解释器模式执行程序
2. -Xcomp完全采用即时编译器模式执行程序。如果即时编译出现问题解释器会介入执行
3. -Xmixed采用解释器+即时编译器的混合模式共同执行程序。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.1.0/JVM/chapter_008/0015.png">
**代码测试**
```java
/**
* 测试解释器模式和JIT编译模式
* -Xint : 6520ms
* -Xcomp : 950ms
* -Xmixed : 936ms
*/
public class IntCompTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
testPrimeNumber(1000000);
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
}
public static void testPrimeNumber(int count){
for (int i = 0; i < count; i++) {
//计算100以内的质数
label:for(int j = 2;j <= 100;j++){
for(int k = 2;k <= Math.sqrt(j);k++){
if(j % k == 0){
continue label;
}
}
//System.out.println(j);
}
}
}
}
```
结论:只用解释器执行是真的慢
### HotSpotVM JIT 分类
在HotSpot VM中内嵌有两个JIT编译器分别为Client Compiler和Server Compiler但大多数情况下我们简称为C1编译器 和 C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器如下所示
1. -client指定Java虚拟机运行在Client模式下并使用C1编译器
* C1编译器会对字节码进行简单和可靠的优化耗时短以达到更快的编译速度。
2. -server指定Java虚拟机运行在server模式下并使用C2编译器。
* C2进行耗时较长的优化以及激进优化但优化的代码执行效率更高。使用C++
### C1和C2编译器不同的优化策略
1. 在不同的编译器上有不同的优化策略C1编译器上主要有方法内联去虚拟化、元余消除。
* 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
* 去虚拟化:对唯一的实现樊进行内联
* 冗余消除:在运行期间把一些不会执行的代码折叠掉
2. C2的优化主要是在全局层面逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化
* 标量替换:用标量值代替聚合对象的属性值
* 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
* 同步消除清除同步操作通常指synchronized
> 也就是说之前的逃逸分析只有在C2server模式下才会触发。那是否说明C1就用不了了
### 分层编译策略
1. 分层编译Tiered Compilation策略程序解释执行不开启性能监控可以触发C1编译将字节码编译成机器码可以进行简单优化也可以加上性能监控C2编译会根据性能监控信息进行激进优化。
2. 不过在Java7版本之后一旦开发人员在程序中显式指定命令“-server"时默认将会开启分层编译策略由C1编译器和C2编译器相互协作共同来执行编译任务。
1. 一般来讲JIT编译出来的机器码性能比解释器解释执行的性能高
2. C2编译器启动时长比C1慢系统稳定执行以后C2编译器执行速度远快于C1编译器
#### Graal 编译器
* 自JDK10起HotSpot又加入了一个全新的即时编译器Graal编译器
* 编译效果短短几年时间就追平了G2编译器未来可期对应还出现了Graal虚拟机是有可能替代Hotspot的虚拟机的
* 目前,带着实验状态标签,需要使用开关参数去激活才能使用
-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler
#### AOT编译器
1. jdk9引入了AoT编译器静态提前编译器Ahead of Time Compiler
2. Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器将所输入的Java类文件转换为机器码并存放至生成的动态共享库之中。
3. 所谓AOT编译是与即时编译相对立的一个概念。我们知道即时编译指的是**在程序的运行过程中**将字节码转换为可在硬件上直接运行的机器码并部署至托管环境中的过程。而AOT编译指的则是**在程序运行之前**,便将字节码转换为机器码的过程。
.java -> .class -> (使用jaotc) -> .so
**AOT编译器编译器的优缺点**
**最大的好处:**
1. Java虚拟机加载已经预编译成二进制库可以直接执行。
2. 不必等待即时编译器的预热减少Java应用给人带来“第一次运行慢” 的不良体验
**缺点:**
1. 破坏了 java “ 一次编译到处运行”必须为每个不同的硬件OS编译对应的发行包
2. 降低了Java链接过程的动态性加载的代码在编译器就必须全部已知。
3. 还需要继续优化中最初只支持Linux X64 java base

View File

@@ -1,820 +0,0 @@
---
title: 'Java并发体系-第二阶段-锁与同步-[1]'
tags:
- Java并发
- 原理
- 源码
categories:
- Java并发
- 原理
keywords: Java并发原理源码
description: '万字系列长文讲解-Java并发体系-第二阶段,从C++和硬件方面讲解。'
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/logo_1.png'
abbrlink: 230c5bb3
date: 2020-10-06 22:09:58
---
> - 本阶段文章讲的略微深入,一些基础性问题不会讲解,如有基础性问题不懂,可自行查看我前面的文章,或者自行学习。
> - 本篇文章比较适合校招和社招的面试笔者在2020年面试的过程中也确实被问到了下面的一些问题。
# 并发编程中的三个问题
> 由于这个东西,和这篇文章比较配。所以虽然在第一阶段写过了,这里再回顾一遍。
## 可见性
### 可见性概念
可见性Visibility是指一个线程对共享变量进行修改另一个线程立即得到修改后的新值。
### 可见性演示
```java
/* 笔记
* 1.当没有加Volatile的时候,while循环会一直在里面循环转圈
* 2.当加了之后Volatile,由于可见性,一旦num改了之后,就会通知其他线程
* 3.还有注意不能用if,if不会重新拉回来再判断一次。(也叫做虚假唤醒)
* 4.案例演示:一个线程对共享变量的修改,另一个线程不能立即得到新值
* */
public class Video04_01 {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread(() ->{
System.out.println(Thread.currentThread().getName() + "\t come in ");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//睡3秒之后再修改num,防止A线程先修改了num,那么到while循环的时候就会直接跳出去了
myData.addTo60();
System.out.println(Thread.currentThread().getName() + "\t come out");
},"A").start();
while(myData.num == 0){
//只有当num不等于0的时候,才会跳出循环
}
}
}
class MyData{
int num = 0;
public void addTo60(){
this.num = 60;
}
}
```
由上面代码可以看出,并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。
## 原子性
### 原子性概念
原子性Atomicity在一次或多次操作中要么所有的操作都成功执行并且不会受其他因素干扰而中 断,要么所有的操作都不执行或全部执行失败。不会出现中间状态
### 原子性演示
案例演示:5个线程各执行1000次 i++;
```java
/**
* @Author: 吕
* @Date: 2019/9/23 15:50
* <p>
* 功能描述: volatile不保证原子性的代码验证
*/
public class Video05_01 {
public static void main(String[] args) {
MyData03 myData03 = new MyData03();
for (int i = 0; i < 20; i++) {
new Thread(() ->{
for (int j = 0; j < 1000; j++) {
myData03.increment();
}
},"线程" + String.valueOf(i)).start();
}
//需要等待上面的20个线程计算完之后再查看计算结果
while(Thread.activeCount() > 2){
Thread.yield();
}
System.out.println("20个线程执行完之后num:\t" + myData03.num);
}
}
class MyData03{
static int num = 0;
public void increment(){
num++;
}
}
```
1、控制台输出由于并发不安全每次执行的结果都可能不一样
> 20个线程执行完之后num: 19706
正常来说如果保证原子性的话20个线程执行完结果应该是20000。控制台输出的值却不是这个说明出现了原子性的问题。
2、使用javap反汇编class文件对于num++可以得到下面的字节码指令:
```java
9: getstatic #12 // Field number:I 取值操作
12: iconst_1
13: iadd
14: putstatic #12 // Field number:I 赋值操作
```
由此可见num++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多线程情况下就可能会出现问题。
比如num刚开始值是7。A线程在执行13: iadd时得到num值是8B线程又执行9: getstatic得到前一个值是7。马上A线程就把8赋值给了num变量。但是B线程已经拿到了之前的值7B线程是在A线程真正赋值前拿到的num值。即使A线程最终把值真正的赋给了num变量但是B线程已经走过了getstaitc取值的这一步B线程会继续在7的基础上进行++操作最终的结果依然是8。本来两个线程对7进行分别进行++操作得到的值应该是9因为并发问题导致结果是8。
3、并发编程时会出现原子性问题当一个线程对共享变量操作到一半时另外的线程也有可能来操作共 享变量,干扰了前一个线程的操作。
## 有序性
### 有序性概念
有序性Ordering是指程序中代码的执行顺序Java在编译时和运行时会对代码进行优化重排序来加快速度会导致程序终的执行顺序不一定就是我们编写代码时的顺序
```java
instance = new SingletonDemo() 是被分成以下 3 步完成
memory = allocate(); 分配对象内存空间
instance(memory); 初始化对象
instance = memory; 设置 instance 指向刚分配的内存地址此时 instance != null
```
步骤2 和 步骤3 不存在数据依赖关系,重排与否的执行结果单线程中是一样的。这种指令重排是被 Java 允许的。当 3 在前时instance 不为 null但实际上初始化工作还没完成会变成一个返回 null 的getInstance。这时候数据就出现了问题。
### 有序性演示
jcstress是java并发压测工具。https://wiki.openjdk.java.net/display/CodeTools/jcstress 修改pom文件添加依赖
```Java
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>${jcstress.version}</version>
</dependency>
```
```java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
@JCStressTest
// @Outcome: 如果输出结果是1或4我们是接受的(ACCEPTABLE)并打印ok
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
//如果输出结果是0我们是接受的并且感兴趣的并打印danger
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class Test03Ordering {
int num = 0;
boolean ready = false;
// 线程1执行的代码
@Actor //@Actor表示会有多个线程来执行这个方法
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2执行的代码
// @Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
```
1、实际上上面两个方法会有很多线程来执行为了讲解方便我们只提出线程1和线程2来讲解。
2、I_Result 是一个保存int类型数据的对象有一个属性 r1 用来保存结果,在多线程情况下可能出现几种结果?
情况1线 程1先执行actor1这时ready = false所以进入else分支结果为1。
情况2线程2执行到actor2执行了num = 2;和ready = true线程1执行这回进入 if 分支,结果为 4。
情况3线程2先执行actor2只执行num = 2但没来得及执行 ready = true线程1执行还是进入 else分支结果为1。
情况40发生了指令重排
```java
// 线程2执行的代码
// @Actor
public void actor2(I_Result r) {
num = 2; //pos_1
ready = true;//pos_2
}
```
pos_1处代码和pos_2处代码没有什么数据依赖关系或者说没有因果关系。Java可能对其进行指令重排排成下面的顺序。
```java
// 线程2执行的代码
// @Actor
public void actor2(I_Result r) {
ready = true;//pos_2
num = 2; //pos_1
}
```
此时如果线程2先执行到`ready = true;`还没来得及执行 `num = 2;` 。线程1执行直接进入if分支此时num默认值为0。 得到的结果也就是0。
# 指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
## 为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,**流水线技术**产生了它的原理是指令1还没有执行完就可以开始执行指令2而不用等到指令1执行结束之后再执行指令2这样就大大提高了效率。
但是,流水线技术最害怕**中断**,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
```java
a = b + c;
d = e - f ;
```
先加载b、c**注意即有可能先加载b也有可能先加载c**但是在执行add(b,c)的时候需要等待b、c装载结束才能继续执行也就是增加了停顿那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,**指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题但是这点牺牲是值得的。**
指令重排一般分为以下三种:
* **编译器优化重排**
编译器在**不改变单线程程序语义**的前提下,可以重新安排语句的执行顺序。
* **指令并行重排**
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果**不存在数据依赖性**(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
* **内存系统重排**
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0001.png">
**指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致**。所以在多线程下,指令重排序可能会导致一些问题。
## as-if-serial语义
as-if-serial语义的意思是不管编译器和CPU如何重排序必须保证在单线程情况下程序的结果是正确的。 以下数据有依赖关系,不能重排序。
写后读:
```
int a = 1;
int b = a;
```
写后写:
```
int a = 1;
int a = 2;
```
读后写:
```
int a = 1;
int b = a;
int a = 2;
```
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
```
int a = 1;
int b = 2;
int c = a + b;
```
# Java内存模型(JMM)
在介绍Java内存模型之前先来看一下到底什么是计算机内存模型。
## 计算机结构
### 计算机结构简介
冯诺依曼,提出计算机由五大组成部分,输入设备,输出设备存储器,控制器,运算器。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0002.png">
输入设备:鼠标,键盘等等
输出设备:显示器,打印机等等
存储器:内存条
运算器和控制器组成CPU
### CPU
中央处理器是计算机的控制和运算的核心我们的程序终都会变成指令让CPU去执行处理程序中 的数据。
### 内存
我们的程序都是在内存中运行的内存会保存程序运行时的数据供CPU处理。
### 缓存
CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内 存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。靠近CPU 的缓存称为L1然后依次是 L2L3和主内存CPU缓存模型如图下图所示。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0003.png">
CPU Cache分成了三个级别: L1 L2 L3。级别越小越接近CPU速度也更快同时也代表着容量越小。速度越快的价格越贵。
1、L1是接近CPU的它容量小例如32K速度快每个核上都有一个L1 Cache。
2、L2 Cache 更大一些例如256K速度要慢一些一般情况下每个核上都有一个独立的L2 Cache。
3、L3 Cache是三级缓存中大的一级例如12MB同时也是缓存中慢的一级在同一个CPU插槽 之间的核共享一个L3 Cache。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0004.png">
上面的图中有一个Latency指标。比如Memory这个指标为59.4ns表示CPU在操作内存的时候有59.4ns的延迟一级缓存最快只有1.2ns。
**CPU处理数据的流程**
Cache的出现是为了解决CPU直接访问内存效率低下问题的。
1、程序在运行的过程中CPU接收到指令 后它会先向CPU中的一级缓存L1 Cache去寻找相关的数据如果命中缓存CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人当运算结束之后再将CPUCache中的新数据刷新 到主内存当中CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能 力。
2、但是由于一级缓存L1 Cache容量较小所以不可能每次都命中。这时CPU会继续向下一级的二 级缓存L2 Cache寻找同样的道理当所需要的数据在二级缓存中也没有的话会继续转向L3 Cache、内存(主存)和硬盘。
## Java内存模型
1、Java Memory Molde (Java内存模型/JMM)千万不要和Java内存结构JVM划分的那个堆方法区混淆。关于“Java内存模型”的权威解释参考 https://download.oracle.com/otn-pub/jcp/memory_model1.0-pfd-spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf。
2、 Java内存模型是Java虚拟机规范中所定义的一种内存模型Java内存模型是标准化的屏蔽掉了底层不同计算机的区别。 Java内存模型是一套规范描述了Java程序中各种变量(线程共享变量)的访问规则以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节具体如下。
3、Java内存模型根据官方的解释主要是在说两个关键字一个是`volatile`,一个是`synchronized`
**主内存**
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
**工作内存**
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操 作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接 访问对方工作内存中的变量。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0005.png">
Java的线程不能直接在主内存中操作共享变量。而是首先将主内存中的共享变量赋值到自己的工作内存中再进行操作操作完成之后刷回主内存。
**Java内存模型的作用**
Java内存模型是一套在多线程读写共享数据时对共享数据的可见性、有序性、和原子性的规则和保障。 synchronized,volatile
## CPU缓存内存与Java内存模型的关系
- 通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解我们应该已经意识到多线程的执行终都会映射到硬件处理器上进行执行。 但Java内存模型和硬件内存架构并不完全一致。
- 对于硬件内存来说只有寄存器、缓存内存、主内存的概念并没有工作内存和主内存之分也就是说Java内存模型对内存的划分对硬件内存并没有任何影响 因为JMM只是一种抽象的概念是一组规则不管是工作内存的数据还是主内存的数据对于计算机硬 件来说都会存储在计算机主内存中当然也有可能存储到CPU缓存或者寄存器中因此总体上来说 Java内存模型和计算机硬件内存架构是一个相互交叉的关系是一种抽象概念划分与真实物理硬件的交叉。
JMM内存模型与CPU硬件内存架构的关系
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0006.png">
工作内存可能对应CPU寄存器也可能对应CPU缓存也可能对应内存。
- Java内存模型是一套规范描述了Java程序中各种变量(线程共享变量)的访问规则以及在JVM中将变量 存储到内存和从内存中读取变量这样的底层细节Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
## 再谈可见性
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0007.png">
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0008.png">
1、图中所示是 个双核 CPU 系统架构 每个核有自己的控制器和运算器其中控制器包含一组寄存器和操作控制器运算器执行算术逻辅运算。每个核都有自己的1级缓存在有些架构里面还有1个所有 CPU 共享的2级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 或者 L2 存或者 CPU 寄存器。
2、一个线程操作共享变量时它首先从主内存复制共享变量到自己的工作内存然后对工作内存里的变量进行处理处理完后将变量值更新到主内存。
3、那么假如线程A和线程B同时处理一个共享变量会出现什么情况?我们使用图所示CPU架构假设线程A和线程B使用不同CPU执行并且当前两级Cache都为空那么这时候由于Cache的存在将会导致内存不可见问题具体看下面的分析。
- 线程A首先获取共享变量X的值由于两级Cache都没有命中所以加载主内存中X的值假如为0。然后把X=0的值缓存到两级缓存线程A修改X的值为1然后将其写入两级Cache并且刷新到主内存。线程A操作完毕后线程A所在的CPU的两级Cache 内和主内存里面的X的值都是1。
- 线程B获取X的值首先一级缓存没有命中然后看二级缓存二级缓存命中了所以返回X=1;到这里一切都是正常的因为这时候主内存中也是X=1。然后线程B修改X的值为2并将其存放到线程2所在的一级Cache和共享二级Cache中最后更新主内存中X 的值为2;到这里一切都是好的。
- 线程A 这次又需要修改X的值获取时一级缓存命中并且X=1到这里问题就出现了明明线程B已经把X的值修改为了2为何线程A获取的还是1呢?这就是共享变量的内存不可见问题也就是线程B写入的值对线程A不可见。那么如何解决共享变量内存不可见问题?使用Java中的volatile和synchronized关键字就可以解决这个问题下面会有讲解。
# 主内存与工作内存之间的交互
为了保证数据交互时数据的正确性Java内存模型中定义了8种操作来完成这个交互过程这8种操作本身都是原子性的。虚拟机实现时必须保证下面 提及的每一种操作都是原子的、不可再分的。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0009.png">
> (1)lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
>
> (2)unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
>
> (3)read:作用于主内存的变量它把一个变量的值从主内存传输到线程的工作内存中以便随后的load动作使用。
>
> (4)load:作用于工作内存的变量它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
>
> (5)use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时都会执行这个操作。
>
> (6)assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
>
> (7)store:作用于工作内存的变量它把工作内存中一个变量的值传送到主内存中以便随后的write使用。
>
> (8)write:作用于主内存的变量它把store操作从工作内存中得到的变量的值放入主内存的变量中。
注意:
1. 如果对一个变量执行lock操作将会清空工作内存中此变量的值
2. 对一个变量执行unlock操作之前必须先把此变量同步到主内存中
3. lock和unlock操作只有加锁才会有。synchronized就是通过这样来保证可见性的。
如果没有synchronized那就是下面这样的
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0010.png">
# happens-before
## 什么是happens-before?
一方面程序员需要JMM提供一个强的内存模型来编写代码另一方面编译器和处理器希望JMM对它们的束缚越少越好这样它们就可以最可能多的做优化来提高性能希望的是一个弱的内存模型。
JMM考虑了这两种需求并且找到了平衡点对编译器和处理器来说**只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。**
而对于程序员JMM提供了**happens-before规则**JSR-133规范满足了程序员的需求——**简单易懂,并且提供了足够强的内存可见性保证。**换言之程序员只要遵循happens-before规则那他写的程序就能保证在JMM中具有强的内存可见性。
JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内也可以是不同的线程之间。因此JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
happens-before关系的定义如下
1. 如果一个操作happens-before另一个操作那么第一个操作的执行结果将对第二个操作可见而且第一个操作的执行顺序排在第二个操作之前。
2. **两个操作之间存在happens-before关系并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致那么JMM也允许这样的重排序。**
happens-before关系本质上和as-if-serial语义是一回事。
as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,**如果操作A happens-before操作B那么操作A在内存上所做的操作对操作B都是可见的不管它们在不在一个线程。**
## 天然的happens-before关系
在Java中有以下天然的happens-before关系
* 1、程序次序规则一个线程内按照代码顺序书写在前面的操作先行发生于书写在后面的操作
* 2、锁定规则一个unLock操作先行发生于后面对同一个锁的lock操作比如说在代码里有先对一个lock.lock()lock.unlock()lock.lock()
* 3、volatile变量规则对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作volatile变量写再是读必须保证是先写再读
* 4、传递规则如果操作A先行发生于操作B而操作B又先行发生于操作C则可以得出操作A先行发生于操作C
* 5、线程启动规则Thread对象的start()方法先行发生于此线程的每个一个动作thread.start()thread.interrupt()
* 6、线程中断规则对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
* 7、线程终结规则线程中所有的操作都先行发生于线程的终止检测我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
* 8、对象终结规则一个对象的初始化完成先行发生于他的finalize()方法的开始
上面这8条原则的意思很显而易见就是程序中的代码如果满足这个条件就一定会按照这个规则来保证指令的顺序。
**举例1**
```java
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
```
根据以上介绍的happens-before规则假如只有一个线程那么不难得出
```
1> A happens-before B
2> B happens-before C
3> A happens-before C
```
注意真正在执行指令的时候其实JVM有可能对操作A & B进行重排序因为无论先执行A还是B他们都对对方是可见的并且不影响执行结果。
如果这里发生了重排序这在视觉上违背了happens-before原则但是JMM是允许这样的重排序的。
所以我们只关心happens-before规则不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。
重排序有两类JMM对这两类重排序有不同的策略
* 会改变程序执行结果的重排序,比如 A -> CJMM要求编译器和处理器都禁止这种重排序。
* 不会改变程序执行结果的重排序,比如 A -> BJMM对编译器和处理器不做要求允许这种重排序。
**举例2**
```Java
//伪代码
volatile boolean flag = false;
//线程1
prepare();
flag = false;
//线程2
while(!flag){
sleep();
}
//基于准备好的资源进行操作
execute();
```
这8条原则是避免说出现乱七八糟扰乱秩序的指令重排要求是这几个重要的场景下比如是按照顺序来但是8条规则之外可以随意重排指令。
比如这个例子如果用volatile来修饰flag变量一定可以让prepare()指令在flag = true之前先执行这就禁止了指令重排。
因为volatile要求的是volatile前面的代码一定不能指令重排到volatile变量操作后面volatile后面的代码也不能指令重排到volatile前面。
# volatile
volatile不保证原子性只保证可见性和禁止指令重排
## CPU术语介绍
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0011.png">
```java
private static volatile SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 执行单例构造函数");
}
public static SingletonDemo getInstance(){
if(instance == null){
synchronized (SingletonDemo.class){
if(instance == null){
instance = new SingletonDemo(); //pos_1
}
}
}
return instance;
}
```
**pos_1处的代码转换成汇编代码如下**
```shell
0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);
```
## volatile保证可见性原理
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码通过查IA-32架 构软件开发者手册可知Lock前缀的指令在多核处理器下会引发了两件事情。
1将当前处理器缓存行的数据写回到系统内存。
2这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度处理器不直接和主内存进行通信而是先将系统内存的数据读到内部缓存L1L2或其他后再进行操作但操作完不知道何时会写到内存。如果对声明了volatile的 变量进行写操作JVM就会向处理器发送一条Lock前缀的指令将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存如果其他处理器缓存的值还是旧的再执行计算操作就会有问题。所以在多处理器下为了保证各个处理器的缓存是一致的就会实现MESI缓存一致性协议每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了当处理器发现自己缓存行对应的内存地址被修改就会将当前处理器的缓存行设置成无效状态当处理器对这个数据进行修改操作的时候会重新从系统内存中把数据读到处理器缓存里。
> 注意lock前缀指令是同时保证可见性和有序性也就是禁止指令重排
> 注意lock前缀指令相当于一个内存屏障【后文讲】
## volatile禁止指令重排的原理
```java
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
```
在JSR-133之前的旧的Java内存模型中是允许volatile变量与普通变量重排序的。那上面的案例中可能就会被重排序成下列时序来执行
1. 线程A写volatile变量step 2设置flag为true
2. 线程B读同一个volatilestep 3读取到flag为true
3. 线程B读普通变量step 4读取到 a = 0
4. 线程A修改普通变量step 1设置 a = 1
可见如果volatile变量与普通变量发生了重排序虽然volatile变量能保证内存可见性也可能导致普通变量读取错误。
所以在旧的内存模型中volatile的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供一种比锁更轻量级的**线程间的通信机制****JSR-133**专家组决定增强volatile的内存语义严格限制编译器和处理器对volatile变量与普通变量的重排序。
编译器还好说JVM是怎么还能限制处理器的重排序的呢它是通过**内存屏障**来实现的。
什么是内存屏障硬件层面内存屏障分两种读屏障Load Barrier和写屏障Store Barrier。内存屏障有两个作用
1. 阻止屏障两侧的指令重排序;
2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
> 注意这里的缓存主要指的是上文说的CPU缓存如L1L2等
### 保守策略下
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的前面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
编译器在**生成字节码时**,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个**比较保守的JMM内存屏障插入策略**,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义。
> 再逐个解释一下这几个屏障。注下述Load代表读操作Store代表写操作
>
> **LoadLoad屏障**对于这样的语句Load1; LoadLoad; Load2在Load2及后续读取操作要读取的数据被访问前保证Load1要读取的数据被读取完毕。
> **StoreStore屏障**对于这样的语句Store1; StoreStore; Store2在Store2及后续写入操作执行前这个屏障会吧Store1强制刷新到内存保证Store1的写入操作对其它处理器可见。
> **LoadStore屏障**对于这样的语句Load1; LoadStore; Store2在Store2及后续写入操作被刷出前保证Load1要读取的数据被读取完毕。
> **StoreLoad屏障**对于这样的语句Store1; StoreLoad; Load2在Load2及后续所有读取操作执行前保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的冲刷写缓冲器清空无效化队列。在大多数处理器的实现中这个屏障是个万能屏障兼具其它三种内存屏障的功能
对于连续多个volatile变量读或者连续多个volatile变量写编译器做了一定的优化来提高性能比如
> 第一个volatile读;
>
> LoadLoad屏障
>
> 第二个volatile读
>
> LoadStore屏障
**1、下面是保守策略下volatile写插入内存屏障后生成的指令序列示意图**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0012.png">
> 图中的StoreStore屏障可以保证在volatile写之前其前面的所有普通写操作已经对任 意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。这里比较有意思的是volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障比如一个volatile写之后方法立即return。为了保证能正确 实现volatile的内存语义JMM在采取了保守策略在每个volatile写的后面或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时 选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点首先确保正确性然后再去追求执行效率
**2、下面是在保守策略下volatile读插入内存屏障后生成的指令序列示意图**
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0013.png">
> 图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。 LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
**优化举例:**
```java
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
// 其他方法 }
}
```
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0014.png">
注意最后的StoreLoad屏障不能省略。因为第二个volatile写之后方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写为了安全起见编译器通常会在这里插入一个StoreLoad屏障。
上面的优化针对任意处理器平台由于不同的处理器有不同“松紧度”的处理器内存模型内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例图中除最后的StoreLoad屏障外其他的屏障都会被省略。
### X86处理器优化
前面保守策略下的volatile读和写在X86处理器平台可以优化成如下图所示。
X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中volatile写的开销比volatile读的开销会大很多因为执行StoreLoad屏障开销会比较大
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0015.png">
## volatile的用途
> 下面的代码在前面可能已经写过了,这里总结一下
从volatile的内存语义上来看volatile可以保证内存可见性且禁止重排序。
在保证内存可见性这一点上volatile有着与锁相同的内存语义所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个**临界区代码**的执行具有原子性。所以**在功能上锁比volatile更强大在性能上volatile更有优势**。
在禁止重排序这一点上volatile也是非常有用的。比如我们熟悉的单例模式其中有一种实现方式是“双重锁检查”比如这样的代码
```java
public class Singleton {
private static Singleton instance; // 不使用volatile关键字
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
```
如果这里的变量声明不使用volatile关键字是可能会发生错误的。它可能会被重排序
```java
instance = new Singleton(); // 第10行
// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址
// 上述三个步骤可能会被重排序为 1-3-2也就是
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象
```
而一旦假设发生了这样的重排序比如线程A在第10行执行了步骤1和步骤3但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行它会判定instance不为空然后直接返回了一个未初始化完成的instance
所以JSR-133对volatile做了增强后volatile的禁止重排序功能还是非常有用的。

View File

@@ -1,500 +0,0 @@
---
title: 'Java并发体系-第二阶段-锁与同步-[2]'
tags:
- Java并发
- 原理
- 源码
categories:
- Java并发
- 原理
keywords: Java并发原理源码
description: '万字系列长文讲解-Java并发体系-第二阶段,从C++和硬件方面讲解。'
cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/logo_1.png'
abbrlink: '8210870'
date: 2020-10-07 22:10:58
---
# 可见性设计的硬件
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0016.png">
从硬件的级别来考虑一下可见性的问题
**1、第一个可见性的场景**每个处理器都有自己的寄存器register所以多个处理器各自运行一个线程的时候可能导致某个变量给放到寄存器里去接着就会导致各个线程没法看到其他处理器寄存器里的变量的值修改了就有可能在寄存器的级别导致变量副本的更新无法让其他处理器看到。
**2、第二个可见性的场景**然后一个处理器运行的线程对变量的写操作都是针对写缓冲来的store buffer并不是直接更新主内存所以很可能导致一个线程更新了变量但是仅仅是在写缓冲区里罢了没有更新到主内存里去。这个时候其他处理器的线程是没法读到他的写缓冲区的变量值的所以此时就是会有可见性的问题。
**3、第三个可见性的场景**然后即使这个时候一个处理器的线程更新了写缓冲区之后将更新同步到了自己的高速缓存里cache或者是主内存然后还把这个更新通知给了其他的处理器但是其他处理器可能就是把这个更新放到无效队列里去没有更新他的高速缓存。此时其他处理器的线程从高速缓存里读数据的时候读到的还是过时的旧值。【处理器是优先从自己的高速缓存里取读取变量副本】
可见性发生的问题
如果要实现可见性的话其中一个方法就是通过MESI协议这个MESI协议实际上有很多种不同的时间因为他不过就是一个协议罢了具体的实现机制要靠具体底层的系统如何实现。
根据具体底层硬件的不同MESI协议的实现是有区别的。比如说MESI协议有一种实现就是一个处理器将另外一个处理器的高速缓存中的更新后的数据拿到自己的高速缓存中来更新一下这样大家的缓存不就实现同步了然后各个处理器的线程看到的数据就一样了。
# MESI-缓存一致性协议(简介)
1、为了实现MESI协议有两个配套的专业机制要给大家说一下flush处理器缓存、refresh处理器缓存。
- flush处理器缓存他的意思就是把自己更新的值刷新到高速缓存里去或者是主内存因为必须要刷到高速缓存或者是主内存才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存或者是主内存里读取到更新的值
- 除了flush以外他还会发送一个消息到总线bus通知其他处理器某个变量的值被他给修改了
- refresh处理器缓存他的意思就是说处理器中的线程在读取一个变量的值的时候如果发现其他处理器的线程更新了变量的值必须从其他处理器的高速缓存或者是主内存读取这个最新的值更新到自己的高速缓存中
2、所以说为了保证可见性在底层是通过MESI协议、flush处理器缓存和refresh处理器缓存这一整套机制来保障的
3、要记住flush和refresh这两个操作flush是强制刷新数据到高速缓存主内存不要仅仅停留在写缓冲器里面refresh是从总线嗅探发现某个变量被修改必须强制从其他处理器的高速缓存或者主内存加载变量的最新值到自己的高速缓存里去。【不同的硬件实现可能略有不同】
4、内存屏障的使用在底层硬件级别的原理其实就是在执行flush和refreshMESI协议是如何与内存屏障搭配使用的flush、refresh
```java
volatile boolean isRunning = true;
isRunning = false; => 写volatile变量就会通过执行一个内存屏障在底层会触发flush处理器缓存的操作while(isRunning) {}读volatile变量也会通过执行一个内存屏障在底层触发refresh操作
```
# 内存屏障的相关讲解
> 上面的文章可能已经把读者搞混了,其实可见性和有序性最主要的就是内存屏障,下面来介绍下内存屏障帮读者梳理一下。
内存屏障是被插入两个CPU指令之间的一种指令用来禁止处理器指令发生重排序像屏障一样从而保障有序性的。另外为了达到屏障的效果它也会使处理器写入、读取值之前将写缓冲器的值写入高速缓存清空无效队列实现可见性。
举例:将写缓冲器数据写入高速缓存,能够避免不同处理器之间不能访问写缓冲器而导致的可见性问题,以及有效地避免了存储转发问题;清空无效队列保证该处理器上高速缓存中不存在旧的副本,进而拿到最新数据
## 基本内存屏障
- LoadLoad屏障 对于这样的语句 Load1; LoadLoad; Load2在Load2及后续读取操作要读取的数据被访问前保证Load1要读取的数据被读取完毕。
- StoreStore屏障对于这样的语句 Store1; StoreStore; Store2在Store2及后续写入操作执行前保证Store1的写入操作对其它处理器可见。
- LoadStore屏障对于这样的语句Load1; LoadStore; Store2在Store2及后续写入操作被执行前保证Load1要读取的数据被读取完毕。
- StoreLoad屏障对于这样的语句Store1; StoreLoad; Load2在Load2及后续所有读取操作执行前保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的冲刷写缓冲器清空无效化队列。在大多数处理器的实现中这个屏障是个万能屏障兼具其它三种内存屏障的功能。
以上的四种屏障主要依据不同处理器支持的重排序读写读读写写写读来确定的比如某些处理器只支持写读重排序因此只需要StoreLoad屏障
下面对上述的基本屏障进行利用,以针对不同的目的用相应的屏障。
## 可见性保障
主要分为加载屏障Load Barrier和存储屏障Store Barrier
- 加载屏障StoreLoad屏障作为万能屏障作用是冲刷写缓冲器清空无效化队列这样处理器在读取共享变量时因为本高速缓存中的数据是无效的因此先从主内存或其他处理器的高速缓存中读取相应变量更新到自己的缓存中
- 存储屏障同样使用StoreLoad屏障作用是将写缓冲器内容写入高速缓存中使处理器对共享变量的更新写入高速缓存或者主内存中 ,同时解决存储转发问题,使得写缓冲器中的数据不存在旧值
以上两种屏障解决可见性问题。
## 有序性保障
主要分为获取屏障Acquire Barrier和释放屏障Release Barrier
- 获取屏障相当于LoadLoad屏障和LoadStore屏障的组合它能禁止该屏障之前的任何读操作与该屏障之后的任何读、写操作之间进行重排序
- 释放屏障相当于StoreLoad屏障与StoreStore屏障的组合它能够禁止该屏障之前的任何读、写操作与该屏障之后的任何写操作之间进行重排序
对于其实大家记住volatile修饰的字段和普通修饰的字段同样不可以重排序因此只要存在读写、写写、写读、读读等操作包含了volatile关键字都会在操作指令之间插入屏障的具体插入什么屏障可以根据对应的操作插入。
## synchronized
```
结论:
1原子性加锁和释放锁ObjectMonitor
2可见性加了Load屏障和Store屏障释放锁flush数据加锁会refresh数据
3有序性Acquire屏障和Release屏障保证同步代码块内部的指令可以重排但是同步代码块内部的指令和外面的指令是不能重排的
```
举个例子说明加屏障的顺序:
```java
int b = 0;
int c = 0;
synchronized(this) { -> monitorenter
//Load内存屏障
//Acquire内存屏障
int a = b;
c = 1; // synchronized代码块里面还是可能会发生指令重排
//Release内存屏障
} -> monitorexit
//Store内存屏障
```
1、java的并发技术底层很多都对应了内存屏障的使用包括synchronized他底层也是依托于各种不同的内存屏障来保证可见性和有序性的
2、按照可见性来划分的话内存屏障可以分为Load屏障和Store屏障。
- Load屏障的作用是执行refresh处理器缓存的操作说白了就是对别的处理器更新过的变量从其他处理器的高速缓存或者主内存加载数据到自己的高速缓存来确保自己看到的是最新的数据。
- Store屏障的作用是执行flush处理器缓存的操作说白了就是把自己当前处理器更新的变量的值都刷新到高速缓存或者主内存里去
+ 在monitorexit指令之后会有一个Store屏障让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作刷到高速缓存或者主内存里去然后在monitorenter指令之后会加一个Load屏障执行refresh处理器缓存的操作把别的处理器修改过的最新值加载到自己高速缓存里来
+ 所以说通过Load屏障和Store屏障就可以让synchronized保证可见性。
3、按照有序性保障来划分的话还可以分为Acquire屏障和Release屏障。
- 在monitorenter指令之后Load屏障之后会加一个Acquire屏障这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。在monitorexit指令之前会加一个Release屏障这个屏障的作用是禁止写操作和读写操作之间发生重排序。
- 所以说,通过 Acquire屏障和Release屏障就可以让synchronzied保证有序性只有synchronized内部的指令可以重排序但是绝对不会跟外部的指令发生重排序。
## volatile
```
前面讲过lock前缀指令相当于一个内存屏障lock前缀指令同时保证可见性和有序性
1可见性加了Load屏障和Store屏障释放锁flush数据加锁会refresh数据
2有序性Acquire屏障和Release屏障保证同步代码块内部的指令可以重排但是同步代码块内部的指令和外面的指令是不能重排的
3不保证原子性
```
1、volatile对原子性的保证真的是非常的有限其实主要就是32位jvm中的long/double类型变量的赋值操作是不具备原子性的加上volatile就可以保证原子性了。但是总体上就说不保证原子性。
2、
```java
volatile boolean isRunning = true;
线程1
Release屏障
isRunning = false;
Store屏障
线程2
Load屏障
while(isRunning) {
Acquire屏障
// 代码逻辑
}
```
- 在volatile变量写操作的前面会加入一个Release屏障然后在之后会加入一个Store屏障这样就可以保证volatile写跟Release屏障之前的任何读写操作都不会指令重排然后Store屏障保证了写完数据之后立马会执行flush处理器缓存的操作
- 在volatile变量读操作的前面会加入一个Load屏障这样就可以保证对这个变量的读取时如果被别的处理器修改过了必须得从其他处理器的高速缓存或者主内存中加载到自己本地高速缓存里保证读到的是最新数据在之后会加入一个Acquire屏障禁止volatile读操作之后的任何读写操作会跟volatile读指令重排序
跟之前讲解的volatie读写内存屏障的知识对比一下其实你看一下是类似的意思的。
## 强调
- 其实不要对内存屏障这个东西太较真因为说句实话不同版本的JVM不同的底层硬件都可能会导致加的内存屏障有一些区别所以这个本来就没完全一致的。你只要知道内存屏障是如何保证volatile的可见性和有序性的就可以了
- 看各种并发相关的书和文章,对内存屏障到底是加的什么屏障,莫衷一是,没有任何一个官方权威的说法,因为这个内存屏障太底层了,底层到了涉及到了硬件,硬件不同对内存屏障的实现是不一样的
- 内存屏障这个东西大概来说其实就是大概的给你说一下这个意思尤其是Release屏障Store屏障和Load屏障还好理解一些比较简单Acqurie屏障莫衷一是我也没法给你一个官方的定论
- 如果你一定 要了解清除,到底加的准确的屏障是什么?到底是如何跟上下的指令避免重排的,你自己去研究吧。【我也看过很多的资料,做过很多的研究,硬件对这个东西的实现和承诺,莫衷一是,没有标准和官方定论。-----这句话是某BAT大佬说的】
内存屏障对应的底层的一些基本的硬件级别的原理,也都讲清楚了
# MESI-缓存一致性协议(进阶)
## MESI-初步
1、处理器高速缓存的底层数据结构实际是一个拉链散列表的结构就是有很多个bucket每个bucket挂了很多的cache entry每个cache entry由三个部分组成tag、cache line和flag其中的cache line【缓存行】就是缓存的数据。tag指向了这个缓存数据在主内存中的数据的地址flag标识了缓存行的状态另外要注意的一点是cache line中可以包含多个变量的值
2、处理器会操作一些变量怎么在高速缓存里定位到这个变量呢
- 那么处理器在读写高速缓存的时候实际上会根据变量名执行一个内存地址解码的操作解析出来3个东西index、tag和offset。index用于定位到拉链散列表中的某个buckettag是用于定位cache entryoffset是用于定位一个变量在cache line中的位置
- 如果说可以成功定位到一个高速缓存中的数据而且flag还标志着有效则缓存命中否则不满足上述条件就是缓存未命中。如果是读数据未命中的话会从主内存重新加载数据到高速缓存中现在处理器一般都有三级高速缓存L1、L2、L3越靠前面的缓存读写速度越快
3、因为有高速缓存的存在所以就导致各个处理器可能对一个变量会在自己的高速缓存里有自己的副本这样一个处理器修改了变量值别的处理器是看不到的所以就是为了这个问题引入了缓存一致性协议MESI协议
4、MESI协议规定对一个共享变量的读操作可以是多个处理器并发执行的但是如果是对一个共享变量的写操作只有一个处理器可以执行其实也会通过排他锁的机制保证就一个处理器能写
之前说过那个cache entry的flag代表了缓存数据的状态MESI协议中划分为
- invalid无效的标记为I这个意思就是当前cache entry无效里面的数据不能使用
- shared共享的标记为S这个意思是当前cache entry有效而且里面的数据在各个处理器中都有各自的副本但是这些副本的值跟主内存的值是一样的各个处理器就是并发的在读而已
- exclusive独占的标记为E这个意思就是当前处理器对这个数据独占了只有他可以有这个副本其他的处理器都不能包含这个副本
- modified修改过的标记为M只能有一个处理器对共享数据更新所以只有更新数据的处理器的cache entry才是exclusive状态表明当前线程更新了这个数据这个副本的数据跟主内存是不一样的
MESI协议规定了一组消息就说各个处理器在操作内存数据的时候都会往总线发送消息而且各个处理器还会不停的从总线嗅探最新的消息通过这个总线的消息传递来保证各个处理器的协作
**下面来详细的图解MESI协议的工作原理**
1、处理器0读取某个变量的数据时首先会根据index、tag和offset从高速缓存的拉链散列表读取数据如果发现状态为I也就是无效的此时就会发送read消息到总线
2、接着主内存会返回对应的数据给处理器0处理器0就会把数据放到高速缓存里同时cache entry的flag状态是S
3、在处理器0对一个数据进行更新的时候如果数据状态是S则此时就需要发送一个invalidate消息到总线尝试让其他的处理器的高速缓存的cache entry全部变为I以获得数据的独占锁。
4、其他的处理器1会从总线嗅探到invalidate消息此时就会把自己的cache entry设置为I也就是过期掉自己本地的缓存然后就是返回invalidate ack消息到总线传递回处理器0处理器0必须收到所有处理器返回的ack消息
5、接着处理器0就会将cache entry先设置为E独占这条数据在独占期间别的处理器就不能修改数据了因为别的处理器此时发出invalidate消息这个处理器0是不会返回invalidate ack消息的除非他先修改完再说
6、接着处理器0就是修改这条数据接着将数据设置为M也有可能是把数据此时强制写回到主内存中具体看底层硬件实现
7、然后其他处理器此时这条数据的状态都是I了那如果要读的话全部都需要重新发送read消息从主内存或者是其他处理器来加载这个具体怎么实现要看底层的硬件了都有可能的
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0017.jpg">
## MESI-优化
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0018.png">
MESI协议如果每次写数据的时候都要发送invalidate消息等待所有处理器返回ack然后获取独占锁后才能写数据那可能就会导致性能很差了因为这个对共享变量的写操作实际上在硬件级别变成串行的了。所以为了解决这个问题硬件层面引入了写缓冲器和无效队列
1、
写缓冲器的作用是一个处理器写数据的时候直接把数据写入缓冲器同时发送invalidate消息然后就认为写操作完成了接着就干别的事儿了不会阻塞在这里。接着这个处理器如果之后收到其他处理器的ack消息之后才会把写缓冲器中的写结果拿出来通过对cache entry设置为E加独占锁同时修改数据然后设置为M。
其实写缓冲器的作用就是处理器写数据的时候直接写入缓冲器不需要同步阻塞等待其他处理器的invalidate ack返回这就大大提升了硬件层面的执行效率了
包括查询数据的时候,会先从写缓冲器里查,因为有可能刚修改的值在这里,然后才会从高速缓存里查,这个就是存储转发
2、
引入无效队列就是说其他处理器在接收到了invalidate消息之后不需要立马过期本地缓存直接把消息放入无效队列就返回ack给那个写处理器了这就进一步加速了性能然后之后从无效队列里取出来消息过期本地缓存即可
通过引入写缓冲器和无效队列一个处理器要写数据的话这个性能其实很高的他直接写数据到写缓冲器发送一个validate消息出去就立马返回执行别的操作了其他处理器收到invalidate消息之后直接放入无效队列立马就返回invalidate ack
## 硬件层面的MESI协议为何会引发有序性和可见性的问题
MESI协议在硬件层面的原理其实大家都已经了解的很清晰了。
讲了这么多再来看一下MESI-协议为何会引发可见性和有序性的问题
- 可见性写缓冲器和无效队列导致的写数据不一定立马写入自己的高速缓存或者主内存是因为可能写入了写缓冲器读数据不一定立马从别人的高速缓存或者主内存刷新最新的值过来invalidate消息在无效队列里面
- 有序性:
简单的举两个例子
1StoreLoad重排序
```java
int a = 0;
int c = 1;
线程1
a = 1; //Store操作
int b = c; //因为要读C的值所以这个是load操作
```
这个很简单吧第一个是Store第二个是Load。但是可能处理器对store操作先写入了写缓冲器此时这个写操作相当于没执行。然后就执行了第二行代码第二行代码的b是局部变量那这个操作等于是读取c的值是load操作。
- 第一个store操作写到写缓冲器里去了导致其他的线程是读不到的看不到的好像是第一个写操作没执行一样第二个load操作成功的执行了
- 这就导致好像第二行代码的load先执行了第一行代码的store后执行
StoreLoad重排明明Store先执行Load后执行看起来好像Load先执行Store后执行
2StoreStore重排序
```java
resource = loadResource();
loaded = true;
```
- 两个写操作但是可能第一个写操作写入了写缓冲器然后第二个写操作是直接修改的高速缓存【可能此时第二个数据的状态是m】这个时候不就导致了两个写操作顺序颠倒了诸如此类的重排序都可能会因为MESI的机制发生
- 可见性问题也是一样的写入写缓冲器之后没刷入高速缓存导致别人读不到读数据的时候可能invalidate消息在无效队列里导致没法立马感知到过期的缓存立马加载最新的数据
## 内存屏障在硬件层面的实现原理
1、可见性问题
> Store屏障 + Load屏障
如果加了Store屏障之后就会强制性要求你对一个写操作必须阻塞等待到其他的处理器返回invalidate ack之后对数据加锁然后修改数据到高速缓存中必须在写数据之后强制执行flush操作。
他的效果,要求一个写操作必须刷到高速缓存(或者主内存),不能停留在写缓冲里
如果加了Load屏障之后在从高速缓存中读取数据的时候如果发现无效队列里有一个invalidate消息此时会立马强制根据那个invalidate消息把自己本地高速缓存的数据设置为I过期然后就可以强制从其他处理器的高速缓存中加载最新的值了。这就是refresh操作
2、有序性问题
> 内存屏障Acquire屏障Release屏障但是都是由基础的StoreStore屏障,StoreLoad屏障可以避免指令重排序的效果
StoreStore屏障会强制让写数据的操作全部按照顺序写入写缓冲器里他不会让你第一个写到写缓冲器里去第二个写直接修改高速缓存了。
```java
resource = loadResource();
StoreStore屏障
loaded = true;
```
StoreLoad屏障他会强制先将写缓冲器里的数据写入高速缓存中接着读数据的时候强制清空无效队列对里面的validate消息全部过期掉高速缓存中的条目然后强制从主内存里重新加载数据
a = 1; // 强制要求必须直接写入高速缓存,不能停留在写缓冲器里,清空写缓冲器里的这条数据 store
int b = c; //load
> java内存模型是对底层的硬件模型cpu缓存模型做了大幅度的简化提供一个抽象和统一的模型给java程序员易于理解很多时候如果要理解一些技术的本质还是要深入到底层去研究的。
# 原子操作的实现原理
原子atomic本意是“不能被进一步分割的最小粒子”而原子操作atomic operation意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
## 相关术语
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0019.png">
## 处理器如何实现原子操作
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操 作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的意思是当一个处理器读取一个字节时其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的但是复杂的内存操作处理器是不能自动保证其原子性的比如跨总线宽度、跨多个缓存行和跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
### 使用总线锁保证原子性
**第一个机制是通过总线锁保证原子性。**如果多个处理器同时对共享变量进行读改写操作 i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操 作就不是原子的操作完之后共享变量的值会和期望的不一致。举个例子如果i=1我们进行 两次i++操作我们期望的结果是3但是有可能结果是2如图所示。
<img src="https://cdn.jsdelivr.net/gh/youthlql/lqlp@v1.0.0/Java_concurrency/Source_code/Second_stage/0020.png">
原因可能是多个处理器同时从各自的缓存中读取变量i分别进行加1操作然后分别写入 系统内存中。那么想要保证读改写共享变量的操作是原子的就必须保证CPU1读改写共享 变量的时候CPU2不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK信号当一个处理器在总线上输出此信号时其他处理器的请求将被阻塞住那么该处理器可以独占共享内存。
### 使用缓存锁保证原子性
**第二个机制是通过缓存锁定来保证原子性**。在同一时刻,我们只需保证对某个内存地址 的操作是原子性即可但总线锁定把CPU和内存之间的通信锁住了这使得锁定期间其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里那么原子操作就可以直接在处理器内部缓存中进行并不需要声明总线锁在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中并且在Lock操作期间被锁定那么当它执行锁操作回写到内存时处理器不在总线上声言LOCK信号而是修改内部的内存地址并允许它的缓存一致性机制来保证操作的原子性因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据当其他处理器回写已被锁定的缓存行的数据时会使缓存行无效在如上图所示的例子中当CPU1修改缓存行中的i时使用了缓存锁定那么CPU2就不能同时缓存i的缓存行。
**但是有两种情况下处理器不会使用缓存锁定。**
第一种情况是当操作的数据不能被缓存在处理器内部或操作的数据跨多个缓存行cache line则处理器会调用总线锁定。
第二种情况是有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。针对以上两个机制我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如位测试和修改指令BTS、BTR、BTC交换指令XADD、CMPXCHG以及其他一些操作数和逻辑指令如ADD、OR被这些指令操作的内存区域就会加锁导致其他处理器不能同时访问它。
## Java如何实现原子操作
1使用循环CAS实现原子操作
2使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁JVM实现锁的方式都用了循环CAS即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁当它退出同步块的时候使用循环CAS释放锁。
传统的锁也就是下文要说的重量级锁依赖于系统的同步函数在linux上使用`mutex`互斥锁,最底层实现依赖于`futex`,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了`synchronized`关键字但**运行时并没有多线程竞争,或两个线程接近于交替执行的情况**,使用传统锁机制无疑效率是会比较低的。
futex由一个内核层的队列和一个用户空间层的atomic integer构成。当获得锁时尝试cas更改integer如果integer原始值是0则修改成功该线程获得锁否则就将当期线程放入到 wait queue中即操作系统的等待队列。【及其类似于AQS的设计思想可能AQS就参考了futex的思想】