什么是HTTP

HTTP协议(HyperText Transfer
Protocol,超文本传输协议)是用于从WWW服务器传输超文本到本地浏览器的传输协议。它可以使浏览器更加高效,使网络传输减少。它不仅保证计算机正确快速地传输超文本文档,还确定传输文档中的哪一部分,以及哪部分内容首先显示(如文本先于图形)等。
HTTP是客户端浏览器或其他程序与Web服务器之间的应用层通信协议。在Internet上的Web服务器上存放的都是超文本信息,客户机需要通过HTTP协议传输所要访问的超文本信息。HTTP包含命令和传输信息,不仅可用于Web访问,也可以用于其他因特网/内联网应用系统之间的通信,从而实现各类应用资源超媒体访问的集成。
我们在浏览器的地址栏里输入的网站地址叫做URL (Uniform Resource
Locator,统一资源定位符)。就像每家每户都有一个门牌地址一样,每个网页也都有一个Internet地址。当你在浏览器的地址框中输入一个URL或是单击一个超级链接时,URL就确定了要浏览的地址。浏览器通过超文本传输协议(HTTP),将Web服务器上站点的网页代码提取出来,并翻译成漂亮的网页。

HTTP工作过程

HTTP请求响应模型

HTTP通信机制是在一次完整的 HTTP 通信过程中,客户端与服务器之间将完成下列7个步骤:

  1. 建立 TCP 连接
    在HTTP工作开始之前,客户端首先要通过网络与服务器建立连接,该连接是通过 TCP 来完成的,该协议与 IP 协议共同构建 Internet,即著名的
    TCP/IP 协议族,因此 Internet 又被称作是 TCP/IP 网络。HTTP 是比 TCP
    更高层次的应用层协议,根据规则,只有低层协议建立之后,才能进行高层协议的连接,因此,首先要建立 TCP 连接,一般 TCP 连接的端口号是80;

  2. 客户端向服务器发送请求命令
    一旦建立了TCP连接,客户端就会向服务器发送请求命令;
    例如:GET/sample/hello.jsp HTTP/1.1

  3. 客户端发送请求头信息
    客户端发送其请求命令之后,还要以头信息的形式向服务器发送一些别的信息,之后客户端发送了一空白行来通知服务器,它已经结束了该头信息的发送;

  4. 服务器应答
    客户端向服务器发出请求后,服务器会客户端返回响应;
    例如: HTTP/1.1 200 OK
    响应的第一部分是协议的版本号和响应状态码

  5. 服务器返回响应头信息
    正如客户端会随同请求发送关于自身的信息一样,服务器也会随同响应向用户发送关于它自己的数据及被请求的文档;

  6. 服务器向客户端发送数据
    服务器向客户端发送头信息后,它会发送一个空白行来表示头信息的发送到此为结束,接着,它就以 Content-Type
    响应头信息所描述的格式发送用户所请求的实际数据;

  7. 服务器关闭 TCP 连接
    一般情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接,然后如果客户端或者服务器在其头信息加入了这行代码 Connection:keep- alive ,TCP
    连接在发送后将仍然保持打开状态,于是,客户端可以继续通过相同的连接发送请求。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽。

HTTP协议基础

通过请求和响应的交换达成通信

应用 HTTP 协议时,必定是一端担任客户端角色,另一端担任服务器端角色。仅从一条通信线路来说,服务器端和客服端的角色是确定的。HTTP
协议规定,请求从客户端发出,最后服务器端响应该请求并返回。 换句话说,肯定是先从客户端开始建立通信的,服务器端在没有接收到请求之前不会发送响应。

HTTP 是不保存状态的协议

HTTP 是一种无状态协议。协议自身不对请求和响应之间的通信状态进行保存。也就是说在 HTTP
这个级别,协议对于发送过的请求或响应都不做持久化处理。这是为了更快地处理大量事务,确保协议的可伸缩性,而特意把 HTTP 协议设计成如此简单的。
可是随着 Web 的不断发展,我们的很多业务都需要对通信状态进行保存。于是我们引入了 Cookie 技术。有了 Cookie 再用 HTTP
协议通信,就可以管理状态了。

Cookie 技术通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。Cookie 会根据从服务器端发送的响应报文内的一个叫做 Set-
Cookie 的首部字段信息,通知客户端保存Cookie。当下次客户端再往该服务器发送请求时,客户端会自动在请求报文中加入 Cookie
值后发送出去。服务器端发现客户端发送过来的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录,最后得到之前的状态信息。

持久连接

HTTP 协议的初始版本中,每进行一个 HTTP 通信都要断开一次 TCP 连接。比如使用浏览器浏览一个包含多张图片的 HTML 页面时,在发送请求访问
HTML 页面资源的同时,也会请求该 HTML 页面里包含的其他资源。因此,每次的请求都会造成无畏的 TCP 连接建立和断开,增加通信量的开销。
为了解决上述 TCP 连接的问题,HTTP/1.1 和部分 HTTP/1.0 想出了持久连接的方法。 其特点是,只要任意一端没有明确提出断开连接,则保持
TCP 连接状态。旨在建立一次 TCP 连接后进行多次请求和响应的交互。
在 HTTP/1.1 中,所有的连接默认都是持久连接。

管线化

持久连接使得多数请求以管线化方式发送成为可能。以前发送请求后需等待并接收到响应,才能发送下一个请求。管线化技术出现后,不用等待亦可发送下一个请求。这样就能做到同时并行发送多个请求,而不需要一个接一个地等待响应了。
比如,当请求一个包含多张图片的 HTML
页面时,与挨个连接相比,用持久连接可以让请求更快结束。而管线化技术要比持久连接速度更快。请求数越多,时间差就越明显。

HTTP报文结构

HTTP 报文

用于 HTTP 协议交互的信息被称为 HTTP 报文。请求端(客户端)的 HTTP 报文叫做请求报文;响应端(服务器端)的叫做响应报文。HTTP
报文本身是由多行(用 CR+LF 作换行符)数据构成的字符串文本。

HTTP 报文结构

HTTP 报文大致可分为报文首部和报文主体两部分。两者由最初出现的空行(CR+LF)来划分。通常,并不一定有报文主体。如下:

HTTP报文结构

请求报文结构

请求报文结构

请求报文的首部内容由以下数据组成:

  • 请求行 —— 包含用于请求的方法、请求 URI 和 HTTP 版本。
  • 首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、请求首部、实体首部以及RFC里未定义的首部如 Cookie 等)

请求报文的示例,如:

请求报文示例

响应报文结构

响应报文结构

响应报文的首部内容由以下数据组成:

  • 状态行 —— 包含表明响应结果的状态码、原因短语和 HTTP 版本。
  • 首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、响应首部、实体首部以及RFC里未定义的首部如 Cookie 等)

响应报文的示例,如下:

响应报文示例

HTTPS

HTTP的弊端

HTTP 之所以被 HTTPS 取代,最大的原因就是不安全,至于为什么不安全,看了下面这张图就一目了然了。

HTTP传输过程

由图可见,HTTP
在传输数据的过程中,所有的数据都是明文传输,自然没有安全性可言,特别是一些敏感数据,比如用户密码和信用卡信息等,一旦被第三方获取,后果不堪设想。

加密算法

HTTPS
解决数据传输安全问题的方案就是使用加密算法,具体来说是混合加密算法,也就是对称加密和非对称加密的混合使用,这里有必要先了解一下这两种加密算法的区别和优缺点。

对称加密

对称加密,顾名思义就是加密和解密都是使用同一个密钥,常见的对称加密算法有 DES、3DES 和 AES 等,其优缺点如下:

  • 优点:算法公开、计算量小、加密速度快、加密效率高,适合加密比较大的数据。

  • 缺点:

    1. 交易双方需要使用相同的密钥,也就无法避免密钥的传输,而密钥在传输过程中无法保证不被截获,因此对称加密的安全性得不到保证。
    1. 每对用户每次使用对称加密算法时,都需要使用其他人不知道的惟一密钥,这会使得发收信双方所拥有的钥匙数量急剧增长,密钥管理成为双方的负担。对称加密算法在分布式网络系统上使用较为困难,主要是因为密钥管理困难,使用成本较高。

如果直接将对称加密算法用在 HTTP 中,会是下面的效果:

对称加密传输过程

从图中可以看出,被加密的数据在传输过程中是无规则的乱码,即便被第三方截获,在没有密钥的情况下也无法解密数据,也就保证了数据的安全。但是有一个致命的问题,那就是既然双方要使用相同的密钥,那就必然要在传输数据之前先由一方把密钥传给另一方,那么在此过程中密钥就很有可能被截获,这样一来加密的数据也会被轻松解密。那如何确保密钥在传输过程中的安全呢?这就要用到非对称加密了。

非对称加密

非对称加密,顾名思义,就是加密和解密需要使用两个不同的密钥:公钥(public key)和私钥(private
key)。公钥与私钥是一对,如果用公钥对数据进行加密,只有用对应的私钥才能解密;如果用私钥对数据进行加密,那么只有用对应的公钥才能解密。非对称加密算法实现机密信息交换的基本过程是:甲方生成一对密钥并将其中的一把作为公钥对外公开;得到该公钥的乙方使用公钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的私钥对加密后的信息进行解密。如果对公钥和私钥不太理解,可以想象成一把钥匙和一个锁头,只是全世界只有你一个人有这把钥匙,你可以把锁头给别人,别人可以用这个锁把重要的东西锁起来,然后发给你,因为只有你一个人有这把钥匙,所以只有你才能看到被这把锁锁起来的东西。常用的非对称加密算法是
RSA 算法,其优缺点如下:

  • 优点:算法公开,加密和解密使用不同的钥匙,私钥不需要通过网络进行传输,安全性很高。
  • 缺点:计算量比较大,加密和解密速度相比对称加密慢很多。

由于非对称加密的强安全性,可以用它完美解决对称加密的密钥泄露问题,效果图如下:

非对称加密发送 KEY
的过程

在上述过程中,客户端在拿到服务器的公钥后,会生成一个随机码 (用 KEY 表示,这个 KEY 就是后续双方用于对称加密的密钥),然后客户端使用公钥把 KEY
加密后再发送给服务器,服务器使用私钥将其解密,这样双方就有了同一个密钥 KEY,然后双方再使用 KEY 进行对称加密交互数据。在非对称加密传输 KEY
的过程中,即便第三方获取了公钥和加密后的 KEY,在没有私钥的情况下也无法破解 KEY
(私钥存在服务器,泄露风险极小),也就保证了接下来对称加密的数据安全。而上面这个流程图正是 HTTPS 的雏形,HTTPS
正好综合了这两种加密算法的优点,不仅保证了通信安全,还保证了数据传输效率。

HTTPS原理详解

HTTPS 并非独立的通信协议,而是对 HTTP 的扩展,保证了通信安全,二者关系如下:

HTTP和HTTPS的关系

也就是说 HTTPS = HTTP + SSL / TLS。

接下来就是最重要的 HTTPS 原理解析了,老规矩先上图

HTTPS
加密、解密、验证及数据传输过程

HTTPS 的整个通信过程可以分为两大阶段:证书验证和数据传输阶段,数据传输阶段又可以分为非对称加密和对称加密两个阶段。具体流程按图中的序号讲解。

  1. 客户端请求 HTTPS 网址,然后连接到 server 的 443 端口 (HTTPS 默认端口,类似于 HTTP 的80端口)。

  2. 采用 HTTPS 协议的服务器必须要有一套数字 CA (Certification Authority)证书,证书是需要申请的,并由专门的数字证书认证机构(CA)通过非常严格的审核之后颁发的电子证书 (当然了是要钱的,安全级别越高价格越贵)。颁发证书的同时会产生一个私钥和公钥。私钥由服务端自己保存,不可泄漏。公钥则是附带在证书的信息中,可以公开的。证书本身也附带一个证书电子签名,这个签名用来验证证书的完整性和真实性,可以防止证书被篡改。

  3. 服务器响应客户端请求,将证书传递给客户端,证书包含公钥和大量其他信息,比如证书颁发机构信息,公司信息和证书有效期等。Chrome 浏览器点击地址栏的锁标志再点击证书就可以看到证书详细信息。

![CA 证书](/images/CA 证书.png)

  1. 客户端解析证书并对其进行验证。如果证书没有问题,客户端就会从服务器证书中取出服务器的公钥A。然后客户端还会生成一个随机码 KEY,并使用公钥A将其加密。如果证书不是可信机构颁布,或者证书中的域名与实际域名不一致,或者证书已经过期,就会向访问者显示一个警告,由其选择是否还要继续通信。就像下面这样:

浏览器安全警告

  1. 客户端把加密后的随机码 KEY 发送给服务器,作为后面对称加密的密钥。

  2. 服务器在收到随机码 KEY 之后会使用私钥B将其解密。经过以上这些步骤,客户端和服务器终于建立了安全连接,完美解决了对称加密的密钥泄露问题,接下来就可以用对称加密愉快地进行通信了。

  3. 服务器使用密钥 (随机码 KEY)对数据进行对称加密并发送给客户端,客户端使用相同的密钥 (随机码 KEY)解密数据。

  4. 双方使用对称加密愉快地传输所有数据。

总结

HTTPS 和 HTTP 的区别:

  • 最最重要的区别就是安全性,HTTP 明文传输,不对数据进行加密安全性较差。HTTPS (HTTP + SSL / TLS)的数据传输过程是加密的,安全性较好。
  • 使用 HTTPS 协议需要申请 CA 证书,一般免费证书较少,因而需要一定费用。证书颁发机构如:Symantec、Comodo、DigiCert 和 GlobalSign 等。
  • HTTP 页面响应速度比 HTTPS 快,这个很好理解,由于加了一层安全层,建立连接的过程更复杂,也要交换更多的数据,难免影响速度。
  • 由于 HTTPS 是建构在 SSL / TLS 之上的 HTTP 协议,所以,要比 HTTP 更耗费服务器资源。
  • HTTPS 和 HTTP 使用的是完全不同的连接方式,用的端口也不一样,前者是 443,后者是 80。

HTTPS 的缺点:

  • 在相同网络环境中,HTTPS 相比 HTTP 无论是响应时间还是耗电量都有大幅度上升。
  • HTTPS 的安全是有范围的,在黑客攻击、服务器劫持等情况下几乎起不到作用。
  • 在现有的证书机制下,中间人攻击依然有可能发生。
  • HTTPS 需要更多的服务器资源,也会导致成本的升高。

volatile

作用

保证内存可见性

基本概念

可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。

实现原理

当对非volatile变量进行读写的时候,每个线程先从主内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU
cache中。
volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证了每次读写变量都从主内存中读,跳过CPU
cache这一步。当一个线程修改了这个变量的值,新值对于其他线程是立即得知的。

内存可见性问题

禁止指令重排序

基本概念

指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。
在JDK1.5之后,可以使用volatile变量禁止指令重排序。针对volatile修饰的变量,在读写操作指令前后会插入内存屏障,指令重排序时不能把后面的指令重排序到内存屏

示例说明:  
double r = 2.1; //(1)   
double pi = 3.14;//(2)   
double area = pi*r*r;//(3)1234  

虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。后面写文章分析一下JIT的问题,工作中同事提出了一个有意思的问题。。

指令重排序带来的问题

基于双重检验的单例模式(懒汉型)

public class Singleton3 {  
    private static Singleton3 instance = null;  
  
    private Singleton3() {}  
  
    public static Singleton3 getInstance() {  
        if (instance == null) {  
            synchronized(Singleton3.class) {  
                if (instance == null)  
                    instance = new Singleton3();// 非原子操作  
            }  
        }  
  
        return instance;  
    }  
}12345678910111213141516  
  

instance= new Singleton()并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory =allocate();    //1:分配对象的内存空间   
ctorInstance(memory);  //2:初始化对象   
instance =memory;     //3:设置instance指向刚分配的内存地址123  
  

上面操作2依赖于操作1,但是操作3并不依赖于操作2。所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();    //1:分配对象的内存空间   
instance =memory;     //3:instance指向刚分配的内存地址,此时对象还未初始化  
ctorInstance(memory);  //2:初始化对象123  
  

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

解决办法
用volatile关键字修饰instance变量,使得instance在读、写操作前后都会插入内存屏障,避免重排序。

public class Singleton3 {  
    private static volatile Singleton3 instance = null;  
  
    private Singleton3() {}  
  
    public static Singleton3 getInstance() {  
        if (instance == null) {  
            synchronized(Singleton3.class) {  
                if (instance == null)  
                    instance = new Singleton3();  
            }  
        }  
        return instance;  
    }  
  

volatile关键字提供内存屏障的方式来防止指令被重排,编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

JVM内存屏障插入策略:

  1. 每个volatile写操作的前面插入一个StoreStore屏障;
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障;
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

适用场景

  • volatile是 轻量级同步机制 。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,是一种比synchronized关键字更轻量级的同步机制。
  • volatile 无法同时保证内存可见性和原子性 。加锁机制既可以确保可见性又可以确保原子性,而volatile变量 只能确保可见性
  • volatile不能修饰写入操作依赖当前值的变量。声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。
  • 当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile;
  • volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。

所以从Oracle Java Spec里面可以看到:

  • 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。
  • 如果使用volatile修饰long和double,那么其读写都是原子操作
  • 对于64位的引用地址的读写,都是原子操作
  • 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
  • 推荐JVM实现为原子操作

synchronized

众所周知 synchronized 关键字是解决并发问题常用解决方案,有以下三种使用方式:

  • 同步普通方法,锁的是当前对象。
  • 同步静态方法,锁的是当前 Class 对象。
  • 同步块,锁的是 () 中的对象。

实现原理

JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。

具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。

其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。

而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

锁优化

synchronized 很多都称之为重量锁,JDK1.6 中对 synchronized
进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁轻量锁

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是: 无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
,这几个状态会随着竞争情况逐渐升级。 锁可以升级但不能降级
,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:

Java对象头MarkWord

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

获取锁

当一个线程访问同步块并获取锁时,会在 对象头栈帧中的锁记录
里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark
Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark
Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

释放锁

偏向锁使用了一种 等到竞争出现才释放锁 的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销

如图,偏向锁的撤销,需要等待 全局安全点
(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark
Word 要么 重新偏向于其他线程, 要么 恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

如果配置中关闭偏向锁,则直接进入轻量级锁

轻量级锁

当偏向锁出现锁竞争时,就会升级为轻量级锁。

加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中 创建用于存储锁记录的空间 ,并将对象头中的Mark Word复制到锁记录中,官方称为
Displaced Mark Word 。然后线程尝试使用CAS 将对象头中的Mark Word替换为指向锁记录的指针
。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark
Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

其他优化

适应性自旋

在使用 CAS 时,如果操作失败,CAS 会自旋再次尝试。由于自旋是需要消耗 CPU 资源的,所以如果长期自旋就白白浪费了
CPUJDK1.6加入了适应性自旋:

如果某个锁自旋很少成功获得,那么下一次就会减少自旋。

这里还需要提一下,重量级锁是可以降级的,在GC中STW时,
重量级锁的降级发生于STW阶段,降级对象就是那些仅仅能被VMThread访问而没有其他JavaThread访问的对象。

AQS

AQS架构图

原理概述

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and
Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

CLH队列

AQS数据结构

// 队列的数据结构如下  
// 结点的数据结构  
static final class Node {  
    // 表示该节点等待模式为共享式,通常记录于nextWaiter,  
    // 通过判断nextWaiter的值可以判断当前结点是否处于共享模式  
    static final Node SHARED = new Node();  
    // 表示节点处于独占式模式,与SHARED相对  
    static final Node EXCLUSIVE = null;  
    // waitStatus的不同状态,具体内容见下文的表格  
    static final int CANCELLED =  1;  
    static final int SIGNAL    = -1;  
    static final int CONDITION = -2;  
    static final int PROPAGATE = -3;  
    volatile int waitStatus;  
    // 记录前置结点  
    volatile Node prev;  
    // 记录后置结点  
    volatile Node next;  
    // 记录当前的线程  
    volatile Thread thread;  
    // 用于记录共享模式(SHARED), 也可以用来记录CONDITION队列(见扩展分析)  
    Node nextWaiter;  
    // 通过nextWaiter的记录值判断当前结点的模式是否为共享模式  
    final boolean isShared() {	return nextWaiter == SHARED;}  
    // 获取当前结点的前置结点  
    final Node predecessor() throws NullPointerException { ... }  
    // 用于初始化时创建head结点或者创建SHARED结点  
    Node() {}  
    // 在addWaiter方法中使用,用于创建一个新的结点  
    Node(Thread thread, Node mode) {       
        this.nextWaiter = mode;  
        this.thread = thread;  
    }  
    // 在CONDITION队列中使用该构造函数新建结点  
    Node(Thread thread, int waitStatus) {   
        this.waitStatus = waitStatus;  
        this.thread = thread;  
    }  
}  
// 记录头结点  
private transient volatile Node head;  
// 记录尾结点  
private transient volatile Node tail;  
  

Node状态表(waitStatus,初始化时默认为0)

状态名称 状态值 状态描述
CANCELLED 1 说明当前结点(即相应的线程)是因为超时或者中断取消的,进入该状态后将无法恢复
SIGNAL -1
说明当前结点的后继结点是(或者将要)由park导致阻塞的,当结点被释放或者取消时,需要通过unpark唤醒后继结点(表现为unparkSuccessor()方法)
CONDITION -2
该状态是用于condition队列结点的,表明结点在等待队列中,结点线程等待在Condition上,当其他线程对Condition调用了signal()方法时,会将其加入到同步队列中去
PROPAGATE -3 说明下一次共享式同步状态的获取将会无条件地向后继结点传播

AQS的重要方法

从架构图中可以得知,AQS提供了大量用于自定义同步器实现的Protected方法。自定义同步器实现的相关方法也只是为了通过修改State字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(并不是全部):

方法名 描述
protected boolean isHeldExclusively() 该线程是否正在独占资源。只有用到Condition才需要去实现它。
protected boolean tryAcquire(int arg)
独占方式。arg为获取锁的次数,尝试获取资源,成功则返回True,失败则返回False。
protected boolean tryRelease(int arg)
独占方式。arg为释放锁的次数,尝试释放资源,成功则返回True,失败则返回False。
protected int tryAcquireShared(int arg)
共享方式。arg为获取锁的次数,尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected boolean tryReleaseShared(int arg)
共享方式。arg为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回True,否则返回False。

一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现tryAcquire-tryRelease、tryAcquireShared-
tryReleaseShared中的一种即可。AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。ReentrantLock是独占锁,所以实现了tryAcquire-
tryRelease。

以非公平锁为例,这里主要阐述一下非公平锁与AQS之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。

公平锁和非公平锁加锁流程

以非公平锁ReentrantLock为例,加锁和解锁的流程如下:

ReentrantLock加锁和解锁流程

加锁:

  • 通过ReentrantLock的加锁方法Lock进行加锁操作。
  • 会调用到内部类Sync的Lock方法,由于Sync#lock是抽象方法,根据ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,本质上都会执行AQS的Acquire方法。
  • AQS的Acquire方法会执行tryAcquire方法,但是由于tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。
  • tryAcquire是获取锁逻辑,获取失败后,会执行框架AQS的后续逻辑,跟ReentrantLock自定义同步器无关。

解锁:

  • 通过ReentrantLock的解锁方法Unlock进行解锁。
  • Unlock会调用内部类Sync的Release方法,该方法继承于AQS。
  • Release中会调用tryRelease方法,tryRelease需要自定义同步器实现,tryRelease只在ReentrantLock中的Sync实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。
  • 释放成功后,所有处理由AQS框架完成,与自定义同步器无关。

acquire方法

// 这里不去看tryAcquire、tryRelease方法的具体实现,只知道它们的作用分别为尝试获取同步状态、尝试释放同步状态  
  
public final void acquire(int arg) {  
    // 如果线程直接获取成功,或者再尝试获取成功后都是直接工作,  
    // 如果是从阻塞状态中唤醒开始工作的线程,将当前的线程中断  
    if (!tryAcquire(arg) &&  
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  
        selfInterrupt();  
}  
// 包装线程,新建结点并加入到同步队列中  
private Node addWaiter(Node mode) {  
    Node node = new Node(Thread.currentThread(), mode);  
    Node pred = tail;  
    // 尝试入队, 成功返回  
    if (pred != null) {  
        node.prev = pred;  
        // CAS操作设置队尾  
        if (compareAndSetTail(pred, node)) {  
            pred.next = node;  
            return node;  
        }  
    }  
    // 通过CAS操作自旋完成node入队操作  
    enq(node);  
    return node;  
}  
}  
  

addWaiter主要的流程如下:

  • 通过当前的线程和锁模式新建一个节点。

  • Pred指针指向尾节点Tail。

  • 将New中Node的Prev指针指向Pred。

  • 通过compareAndSetTail方法,完成尾节点的设置。这个方法主要是对tailOffset和Expect进行比较,如果tailOffset的Node和Expect的Node地址是相同的,那么设置Tail的值为Update的值。

    static {
    try {
    stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(“state”));
    headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(“head”));
    tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField(“tail”));
    waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(“waitStatus”));
    nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField(“next”));
    } catch (Exception ex) {
    throw new Error(ex);
    }
    }

从AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是tail对应的偏移量,所以这个时候会将new出来的Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。

  • 如果Pred指针是Null(说明等待队列中没有元素),或者当前Pred指针和Tail指向的位置不同(说明被别的线程已经修改),就需要看一下Enq的方法。

    // java.util.concurrent.locks.AbstractQueuedSynchronizer

    private Node enq(final Node node) {
    for (;;) {
    Node t = tail;
    if (t == null) { // Must initialize
    if (compareAndSetHead(new Node()))
    tail = head;
    } else {
    node.prev = t;
    if (compareAndSetTail(t, node)) {
    t.next = node;
    return t;
    }
    }
    }
    }

如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。

// 在同步队列中等待获取同步状态  
final boolean acquireQueued(final Node node, int arg) {  
    boolean failed = true;  
    try {  
        boolean interrupted = false;  
        // 自旋  
        for (;;) {  
            final Node p = node.predecessor();  
            // 检查是否符合开始工作的条件  
            if (p == head && tryAcquire(arg)) {  
                setHead(node);  
                p.next = null;  
                failed = false;  
                return interrupted;  
            }  
            // 获取不到同步状态,将前置结点标为SIGNAL状态并且通过park操作将node包装的线程阻塞  
            if (shouldParkAfterFailedAcquire(p, node) &&  
                parkAndCheckInterrupt())  
                interrupted = true;  
        }  
    } finally {  
        // 如果获取失败,将node标记为CANCELLED  
        if (failed)  
            cancelAcquire(node);  
    }  

//setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。  
private void setHead(Node node) {  
    head = node;  
    node.thread = null;  
    node.prev = null;  
}  
  
// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
// 靠前驱节点判断当前线程是否应该被阻塞  
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {  
    // 获取头结点的节点状态  
    int ws = pred.waitStatus;  
    // 说明头结点处于唤醒状态  
    if (ws == Node.SIGNAL)  
        return true;   
    // 通过枚举值我们知道waitStatus>0是取消状态  
    if (ws > 0) {  
        do {  
            // 循环向前查找取消节点,把取消节点从队列中剔除  
            node.prev = pred = pred.prev;  
        } while (pred.waitStatus > 0);  
        pred.next = node;  
    } else {  
        // 设置前任节点等待状态为SIGNAL  
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);  
    }  
    return false;  
}  
  

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
private final boolean parkAndCheckInterrupt() {  
    LockSupport.park(this);  
    return Thread.interrupted();  
}  

cancelAcquire方法

private void cancelAcquire(Node node) {  
  // 将无效节点过滤  
    if (node == null)  
        return;  
  // 设置该节点不关联任何线程,也就是虚节点  
    node.thread = null;  
    Node pred = node.prev;  
  // 通过前驱节点,跳过取消状态的node  
    while (pred.waitStatus > 0)  
        node.prev = pred = pred.prev;  
  // 获取过滤后的前驱节点的后继节点  
    Node predNext = pred.next;  
  // 把当前node的状态设置为CANCELLED  
    node.waitStatus = Node.CANCELLED;  
  // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点  
  // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null  
    if (node == tail && compareAndSetTail(node, pred)) {  
        compareAndSetNext(pred, predNext, null);  
    } else {  
        int ws;  
    // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功  
    // 如果1和2中有一个为true,再判断当前节点的线程是否为null  
    // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点  
        if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {  
            Node next = node.next;  
            if (next != null && next.waitStatus <= 0)  
                compareAndSetNext(pred, predNext, next);  
        } else {  
      // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点  
            unparkSuccessor(node);  
        }  
        node.next = node; // help GC  
    }  
}  
  

当前的流程:

  • 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。
  • 根据当前节点的位置,考虑以下三种情况:

(1) 当前节点是尾节点。

(2) 当前节点是Head的后继节点。

(3) 当前节点不是Head的后继节点,也不是尾节点。

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。
shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

do {  
    node.prev = pred = pred.prev;  
} while (pred.waitStatus > 0);  
  

unlock方法

public void unlock() {  
    sync.release(1);  
}  
  

可以看到,本质释放锁的地方,是通过框架来完成的。

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
public final boolean release(int arg) {  
    if (tryRelease(arg)) {  
        Node h = head;  
        if (h != null && h.waitStatus != 0)  
            unparkSuccessor(h);  
        return true;  
    }  
    return false;  
}  
  

在ReentrantLock里面的公平锁和非公平锁的父类Sync定义了可重入锁的释放锁机制。

// java.util.concurrent.locks.ReentrantLock.Sync  
  
// 方法返回当前锁是不是没有被线程持有  
protected final boolean tryRelease(int releases) {  
    // 减少可重入次数  
    int c = getState() - releases;  
    // 当前线程不是持有锁的线程,抛出异常  
    if (Thread.currentThread() != getExclusiveOwnerThread())  
        throw new IllegalMonitorStateException();  
    boolean free = false;  
    // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state  
    if (c == 0) {  
        free = true;  
        setExclusiveOwnerThread(null);  
    }  
    setState(c);  
    return free;  
}  
  

我们来解释下述源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
public final boolean release(int arg) {  
    // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有  
    if (tryRelease(arg)) {  
        // 获取头结点  
        Node h = head;  
        // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态  
        if (h != null && h.waitStatus != 0)  
            unparkSuccessor(h);  
        return true;  
    }  
    return false;  
}  
  

这里的判断条件为什么是h != null && h.waitStatus != 0?

h == null Head还没初始化。初始情况下,head ==
null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。

h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。

h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。

再看一下unparkSuccessor方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
private void unparkSuccessor(Node node) {  
    // 获取头结点waitStatus  
    int ws = node.waitStatus;  
    if (ws < 0)  
        compareAndSetWaitStatus(node, ws, 0);  
    // 获取当前节点的下一个节点  
    Node s = node.next;  
    // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点  
    if (s == null || s.waitStatus > 0) {  
        s = null;  
        // 就从尾部节点开始找,到队首,找到队列第一个waitStatus<0的节点。  
        for (Node t = tail; t != null && t != node; t = t.prev)  
            if (t.waitStatus <= 0)  
                s = t;  
    }  
    // 如果当前节点的下个节点不为空,而且状态<=0,就把当前节点unpark  
    if (s != null)  
        LockSupport.unpark(s.thread);  
}  
  

为什么要从后往前找第一个非Cancelled的节点呢?原因如下。

之前的addWaiter方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
private Node addWaiter(Node mode) {  
    Node node = new Node(Thread.currentThread(), mode);  
    // Try the fast path of enq; backup to full enq on failure  
    Node pred = tail;  
    if (pred != null) {  
        node.prev = pred;  
        if (compareAndSetTail(pred, node)) {  
            pred.next = node;  
            return node;  
        }  
    }  
    enq(node);  
    return node;  
}  
  

我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node)
这两个地方可以看作Tail入队的原子操作,但是此时pred.next =
node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node。

综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和CANCELLED节点产生过程中断开Next指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行acquireQueued方法以后,中断如何处理?

中断恢复后的执行流程

唤醒后,会执行return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
private final boolean parkAndCheckInterrupt() {  
    LockSupport.park(this);  
    return Thread.interrupted();  
}  

再回到acquireQueued代码,当parkAndCheckInterrupt返回True或者False的时候,interrupted的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前interrupted返回。

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
final boolean acquireQueued(final Node node, int arg) {  
    boolean failed = true;  
    try {  
        boolean interrupted = false;  
        for (;;) {  
            final Node p = node.predecessor();  
            if (p == head && tryAcquire(arg)) {  
                setHead(node);  
                p.next = null; // help GC  
                failed = false;  
                return interrupted;  
            }  
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())  
                interrupted = true;  
            }  
    } finally {  
        if (failed)  
            cancelAcquire(node);  
    }  
}  
  

如果acquireQueued为True,就会执行selfInterrupt方法。

// java.util.concurrent.locks.AbstractQueuedSynchronizer  
  
static void selfInterrupt() {  
    Thread.currentThread().interrupt();  
}  
  

该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于Java提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:

  1. 当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为False),并记录下来,如果发现该线程被中断过,就再中断一次。
  2. 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。

原子类

原子操作是指一个不受其他操作影响的操作任务单元。原子操作是在多线程环境下避免数据不一致必须的手段。

int++并不是一个原子操作,所以当一个线程读取它的值并加 1 时,另外一个线程有可能会读到之前的值,这就会引发错误。

为了解决这个问题,必须保证增加操作是原子的,在 JDK1.5 之前我们可以使用同步技术来做到这一点。到
JDK1.5,java.util.concurrent.atomic 包提供了 int 和long
类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。

java.util.concurrent
这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由
JVM 从等待队列中选择另一个线程进入,这只是一种逻辑上的理解。

原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference

原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

原子属性更新器:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater

解决 ABA 问题的原子类:AtomicMarkableReference(通过引入一个
boolean来反映中间有没有变过),AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)

AtomicInteger举例

public class AtomicInteger extends Number implements java.io.Serializable {  
    
    private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();  
    private static final long VALUE;  
  
    private volatile int value;//注意该值用volatile修饰  
  
    public AtomicInteger(int initialValue) {  
        value = initialValue;  
    }  
    //以原子的方式将输入的值与ActomicInteger中的值进行相加,  
    //注意:返回相加前ActomicInteger中的值  
    public final int getAndAdd(int delta) {  
        return U.getAndAddInt(this, VALUE, delta);  
    }  
    //以原子的方式将输入的值与ActomicInteger中的值进行相加,  
    //注意:返回相加后的结果  
    public final int addAndGet(int delta) {  
        return U.getAndAddInt(this, VALUE, delta) + delta;  
    }  
    //以原子方式将当前ActomicInteger中的值加1,  
    //注意:返回相加前ActomicInteger中的值  
    public final int getAndIncrement() {  
        return U.getAndAddInt(this, VALUE, 1);  
    }  
    //以原子方式将当前ActomicInteger中的值加1,  
    //注意:返回相加后的结果  
    public final int incrementAndGet() {  
        return U.getAndAddInt(this, VALUE, 1) + 1;  
    }  
  
    //省略部分代码...  
  }  
  

AtomicInteger内部会调用其中sun.misc.Unsafe方法中getAndAddInt的方法。具体代码如下:

public final int getAndAdd(int delta) {  
       return U.getAndAddInt(this, VALUE, delta);  
   }  
  

而sun.misc.Unsafe方法中getAndAddInt方法又会调用jdk.internal.misc.Unsafe的getAndAddInt,具体代码如下:

public final int getAndAddInt(Object o, long offset, int delta) {  
       return theInternalUnsafe.getAndAddInt(o, offset, delta);  
   }  

jdk.internal.misc.Unsafe的getAndAddInt()方法的声明如下:

public final int getAndAddInt(Object o, long offset, int delta) {  
        int v;  
        do {  
            v = getIntVolatile(o, offset);//先获取内存中存储的值  
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));//如果不是期望的结果值,就一直循环  
        return v;  
    }  
      
//该函数返回值代表CAS操作是否成功      
public final boolean weakCompareAndSetInt(Object o, long offset,  
                                          int expected,  
                                          int x) {  
     return compareAndSetInt(o, offset, expected, x);//执行CAS操作  
    }  
  

从上述代码中我们可以得出,会先获取内存中存储的值,最终会调用compareAndSetInt()方法来完成最终的原子操作。其中compareAndSetInt()方法的返回值代表着该次CAS操作是否成功。如果不成功。那么会一直循环。直到成功为止(也就是循环CAS操作)。

CountDownLatch

CountDownLatch顾名思义,count + down + latch = 计数 + 减 + 门闩。
可以理解这个东西就是个计数器,只能减不能加,同时它还有个门闩的作用,当计数器不为0时,门闩是锁着的;当计数器减到0时,门闩就打开了。

实现原理

构造方法

下面是实现的源码,非常简短,主要是创建了一个Sync对象。

public CountDownLatch(int count) {  
        if (count < 0) throw new IllegalArgumentException("count < 0");  
        this.sync = new Sync(count);  
}  

Sync对象

private static final class Sync extends AbstractQueuedSynchronizer {  
        private static final long serialVersionUID = 4982264981922014374L;  
   
        Sync(int count) {  
            setState(count);  
        }  
   
        int getCount() {  
            return getState();  
        }  
   
        protected int tryAcquireShared(int acquires) {  
            return (getState() == 0) ? 1 : -1;  
        }  
   
        protected boolean tryReleaseShared(int releases) {  
            // Decrement count; signal when transition to zero  
            for (;;) {  
                int c = getState();  
                if (c == 0)  
                    return false;  
                int nextc = c-1;  
                if (compareAndSetState(c, nextc))  
                    return nextc == 0;  
            }  
        }  
    }  

假设我们是这样创建的:new CountDownLatch(5)。其实也就相当于new
Sync(5),相当于setState(5)。setState其实就是共享锁资源总数,我们可以暂时理解为设置一个计数器,当前计数器初始值为5。

tryAcquireShared方法其实就是判断一下当前计数器的值,是否为0了,如果为0的话返回1(
返回1的时候,就表示获取锁成功,awit()方法就不再阻塞 )。

tryReleaseShared方法就是利用CAS的方式,对计数器进行减一的操作,而我们实际上每次调用countDownLatch.countDown()方法的时候,最终都会调到这个方法,对计数器进行减一操作,一直减到0为止。

await()

public void await() throws InterruptedException {      
    sync.acquireSharedInterruptibly(1);      
}  

代码很简单,就一句话(注意acquireSharedInterruptibly()方法是抽象类:AbstractQueuedSynchronizer的一个方法,我们上面提到的Sync继承了它),我们跟踪源码,继续往下看:

acquireSharedInterruptibly(int arg)

public final void acquireSharedInterruptibly(int arg)  
           throws InterruptedException {  
       if (Thread.interrupted())  
           throw new InterruptedException();  
       if (tryAcquireShared(arg) < 0)  
           doAcquireSharedInterruptibly(arg);  
   }  
  

源码也是非常简单的,首先判断了一下,当前线程是否有被中断,如果没有的话,就调用tryAcquireShared(int
acquires)方法,判断一下当前线程是否还需要“阻塞”。其实这里调用的tryAcquireShared方法,就是我们上面提到的java.util.concurrent.CountDownLatch.Sync.tryAcquireShared(int)这个方法。
当然,在一开始我们没有调用过countDownLatch.countDown()方法时,这里tryAcquireShared方法肯定是会返回-1的,因为会进入到doAcquireSharedInterruptibly方法。

private void doAcquireSharedInterruptibly(int arg)  
    throws InterruptedException {  
    final Node node = addWaiter(Node.SHARED);  
    boolean failed = true;  
    try {  
        for (;;) {  
            final Node p = node.predecessor();  
            if (p == head) {  
                int r = tryAcquireShared(arg);  
                if (r >= 0) {  
                    setHeadAndPropagate(node, r);  
                    p.next = null; // help GC  
                    failed = false;  
                    return;  
                }  
            }  
            if (shouldParkAfterFailedAcquire(p, node) &&  
                parkAndCheckInterrupt())  
                throw new InterruptedException();  
        }  
    } finally {  
        if (failed)  
            cancelAcquire(node);  
    }  
}  
  

countDown()方法

// 计数器减1  
public void countDown() {  
    sync.releaseShared(1);   
}  
  
//调用AQS的releaseShared方法  
public final boolean releaseShared(int arg) {  
    if (tryReleaseShared(arg)) {//计数器减一  
        doReleaseShared();//唤醒后继结点,这个时候队列中可能只有调用过await()的线程节点,也可能队列为空,一般为主线程  
        return true;  
    }  
    return false;  
}  
  
//自定义同步器实现的方法  
protected boolean tryReleaseShared(int releases) {  
    // Decrement count; signal when transition to zero  
    for (;;) {  
        int c = getState();  
        if (c == 0)  
            return false;  //重复调用的时候返回false结束上层方法  
        int nextc = c-1;  
        if (compareAndSetState(c, nextc))  
            return nextc == 0;  //调用countDown的线程不把资源释放到0,改方法一直返回 false   
    }  
}  
  

这个时候,我们应该对于countDownLatch.await()方法是怎么“阻塞”当前线程的,已经非常明白了。其实说白了,就是当你调用了countDownLatch.await()方法后,你当前线程就会进入了一个死循环当中,在这个死循环里面,会不断的进行判断,通过调用tryAcquireShared方法,不断判断我们上面说的那个计数器,看看它的值是否为0了(为0的时候,其实就是我们调用了足够多
countDownLatch.countDown()方法的时候),如果是为0的话,tryAcquireShared就会返回1,代码也会进入到if (r >=
0)部分,然后跳出了循环,也就不再“阻塞”当前线程了。需要注意的是,说是在不停的循环,其实也并非在不停的执行for循环里面的内容,因为在后面调用parkAndCheckInterrupt()方法时,在这个方法里面是会调用
LockSupport.park(this);来挂起当前线程。

CountDownLatch 使用的注意点:

  1. 只有当count为0时, await之后的程序才够执行
  2. countDown必须写在finally中,防止发生异程常时,导致程序死锁。

举个栗子

public class Test {  
    public static void main(String[] args) {  
        final CountDownLatch latch = new CountDownLatch(2);  
        new Thread() {  
            public void run() {  
                try {  
                    System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");  
                    Thread.sleep(3000);  
                    System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                } finally {  
                    latch.countDown();  
                }  
            };  
        }.start();  
  
        new Thread() {  
            public void run() {  
                try {  
                    System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");  
                    Thread.sleep(3000);  
                    System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");  
                    latch.countDown();  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                } finally {  
                    latch.countDown();  
                }  
            };  
        }.start();  
        try {  
            System.out.println("等待2个子线程执行完毕...");  
            latch.await();  
            System.out.println("2个子线程已经执行完毕");  
            System.out.println("继续执行主线程");  
        } catch (InterruptedException e) {              
            e.printStackTrace();          
            }     
    }  
}  
  

CyclicBarrier

CyclicBarrier是一个同步辅助类,允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。因为该
barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。

注意比较CountDownLatch和CyclicBarrier:

  1. CountDownLatch的作用是允许1或N个线程等待其他线程完成执行;而CyclicBarrier则是允许N个线程相互等待。

  2. CountDownLatch的计数器无法被重置;CyclicBarrier的计数器可以被重置后使用,因此它被称为是循环的barrier。

CyclicBarrier函数列表

CyclicBarrier(int parties)  
创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,但它不会在启动 barrier 时执行预定义的操作。  
CyclicBarrier(int parties, Runnable barrierAction)  
创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,并在启动 barrier 时执行给定的屏障操作,该操作由最后一个进入 barrier 的线程执行。  
  
int await()  
在所有参与者都已经在此 barrier 上调用 await 方法之前,将一直等待。  
int await(long timeout, TimeUnit unit)  
在所有参与者都已经在此屏障上调用 await 方法之前将一直等待,或者超出了指定的等待时间。  
int getNumberWaiting()  
返回当前在屏障处等待参与者数目。  
int getParties()  
返回要求启动此 barrier 的参与者数目。  
boolean isBroken()  
查询此屏障是否处于损坏状态。  
void reset()  
将屏障重置为其初始状态。  

CyclicBarrier数据结构

CyclicBarrier的UML类图如下:

CyclicBarrier的UML类图

CyclicBarrier是包含了”ReentrantLock对象lock”和”Condition对象trip”,它是通过独占锁实现的。下面通过源码去分析到底是如何实现的。

CyclicBarrier源码分析

构造函数

CyclicBarrier的构造函数共2个:CyclicBarrier 和 CyclicBarrier(int parties, Runnable
barrierAction)。第1个构造函数是调用第2个构造函数来实现的,下面第2个构造函数的源码。

public CyclicBarrier(int parties, Runnable barrierAction) {  
    if (parties <= 0) throw new IllegalArgumentException();  
    // parties表示“必须同时到达barrier的线程个数”。  
    this.parties = parties;  
    // count表示“处在等待状态的线程个数”。  
    this.count = parties;  
    // barrierCommand表示“parties个线程到达barrier时,会执行的动作”。  
    this.barrierCommand = barrierAction;  
}  
  

await()

public int await() throws InterruptedException, BrokenBarrierException {  
    try {  
        return dowait(false, 0L);  
    } catch (TimeoutException toe) {  
        throw new Error(toe); // cannot happen;  
    }  
}  

说明 :await()是通过dowait()实现的。

private int dowait(boolean timed, long nanos)  
    throws InterruptedException, BrokenBarrierException,  
           TimeoutException {  
    final ReentrantLock lock = this.lock;  
    // 获取“独占锁(lock)”  
    lock.lock();  
    try {  
        // 保存“当前的generation”  
        final Generation g = generation;  
  
        // 若“当前generation已损坏”,则抛出异常。  
        if (g.broken)  
            throw new BrokenBarrierException();  
  
        // 如果当前线程被中断,则通过breakBarrier()终止CyclicBarrier,唤醒CyclicBarrier中所有等待线程。  
        if (Thread.interrupted()) {  
            breakBarrier();  
            throw new InterruptedException();  
        }  
  
       // 将“count计数器”-1  
       int index = --count;  
       // 如果index=0,则意味着“有parties个线程到达barrier”。  
       if (index == 0) {  // tripped  
           boolean ranAction = false;  
           try {  
               // 如果barrierCommand不为null,则执行该动作。  
               final Runnable command = barrierCommand;  
               if (command != null)  
                   command.run();  
               ranAction = true;  
               // 唤醒所有等待线程,并更新generation。  
               nextGeneration();  
               return 0;    //这里等价于return index;  
           } finally {  
               if (!ranAction)  
                   breakBarrier();  
           }  
       }  
  
        // 当前线程一直阻塞,直到“有parties个线程到达barrier” 或 “当前线程被中断” 或 “超时”这3者之一发生,  
        // 当前线程才继续执行。  
        for (;;) {  
            try {  
                // 如果不是“超时等待”,则调用awati()进行等待;否则,调用awaitNanos()进行等待。  
                if (!timed)  
                    trip.await();  
                else if (nanos > 0L)  
                    nanos = trip.awaitNanos(nanos);  
            } catch (InterruptedException ie) {  
                // 如果等待过程中,线程被中断,则执行下面的函数。  
                if (g == generation && ! g.broken) {  
                    breakBarrier();  
                    throw ie;  
                } else {  
                    Thread.currentThread().interrupt();  
                }  
            }  
  
            // 如果“当前generation已经损坏”,则抛出异常。  
            if (g.broken)  
                throw new BrokenBarrierException();  
  
            // 如果“generation已经换代”,则返回index。  
            if (g != generation)  
                return index;  
  
            // 如果是“超时等待”,并且时间已到,则通过breakBarrier()终止CyclicBarrier,唤醒CyclicBarrier中所有等待线程。  
            if (timed && nanos <= 0L) {  
                breakBarrier();  
                throw new TimeoutException();  
            }  
        }  
    } finally {  
        // 释放“独占锁(lock)”  
        lock.unlock();  
    }  
}  

说明 :dowait()的作用就是让当前线程阻塞,直到“有parties个线程到达barrier” 或 “当前线程被中断” 或
“超时”这3者之一发生,当前线程才继续执行。
(01) generation是CyclicBarrier的一个成员变量,它的定义如下:

private Generation generation = new Generation();  
  
private static class Generation {  
    boolean broken = false;  
}  

在CyclicBarrier中,同一批的线程属于同一代,即同一个Generation;CyclicBarrier中通过generation对象,记录属于哪一代。
当有parties个线程到达barrier,generation就会被更新换代。

(02)
如果当前线程被中断,即Thread.interrupted()为true;则通过breakBarrier()终止CyclicBarrier。breakBarrier()的源码如下:

private void breakBarrier() {  
    generation.broken = true;  
    count = parties;  
    trip.signalAll();  
}  
  

breakBarrier()会设置当前中断标记broken为true,意味着“将该Generation中断”;同时,设置count=parties,即重新初始化count;最后,通过signalAll()唤醒CyclicBarrier上所有的等待线程。

(03) 将“count计数器”-1,即–count;然后判断是不是“有parties个线程到达barrier”,即index是不是为0。
当index=0时,如果barrierCommand不为null,则执行该barrierCommand,barrierCommand就是我们创建CyclicBarrier时,传入的Runnable对象。然后,调用nextGeneration()进行换代工作,nextGeneration()的源码如下:

private void nextGeneration() {  
    trip.signalAll();  
    count = parties;  
    generation = new Generation();  
}  

首先,它会调用signalAll()唤醒CyclicBarrier上所有的等待线程;接着,重新初始化count;最后,更新generation的值。

(04)
在for(;;)循环中。timed是用来表示当前是不是“超时等待”线程。如果不是,则通过trip.await()进行等待;否则,调用awaitNanos()进行超时等待。

举个栗子

import java.util.concurrent.CyclicBarrier;  
import java.util.concurrent.BrokenBarrierException;  
  
public class CyclicBarrierTest1 {  
  
    private static int SIZE = 5;  
    private static CyclicBarrier cb;  
    public static void main(String[] args) {  
  
        cb = new CyclicBarrier(SIZE);  
  
        // 新建5个任务  
        for(int i=0; i<SIZE; i++)  
            new InnerThread().start();  
    }  
  
    static class InnerThread extends Thread{  
        public void run() {  
            try {  
                System.out.println(Thread.currentThread().getName() + " wait for CyclicBarrier.");  
  
                // 将cb的参与者数量加1  
                cb.await();  
  
                // cb的参与者数量等于5时,才继续往后执行  
                System.out.println(Thread.currentThread().getName() + " continued.");  
            } catch (BrokenBarrierException e) {  
                e.printStackTrace();  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
    }  
}  
运行结果:  
Thread-1 wait for CyclicBarrier.  
Thread-2 wait for CyclicBarrier.  
Thread-3 wait for CyclicBarrier.  
Thread-4 wait for CyclicBarrier.  
Thread-0 wait for CyclicBarrier.  
Thread-0 continued.  
Thread-4 continued.  
Thread-2 continued.  
Thread-3 continued.  
Thread-1 continued.  
  

import java.util.concurrent.CyclicBarrier;  
import java.util.concurrent.BrokenBarrierException;  
  
public class CyclicBarrierTest2 {  
  
    private static int SIZE = 5;  
    private static CyclicBarrier cb;  
    public static void main(String[] args) {  
  
        cb = new CyclicBarrier(SIZE, new Runnable () {  
            public void run() {  
                System.out.println("CyclicBarrier's parties is: "+ cb.getParties());  
            }  
        });  
  
        // 新建5个任务  
        for(int i=0; i<SIZE; i++)  
            new InnerThread().start();  
    }  
  
    static class InnerThread extends Thread{  
        public void run() {  
            try {  
                System.out.println(Thread.currentThread().getName() + " wait for CyclicBarrier.");  
  
                // 将cb的参与者数量加1  
                cb.await();  
  
                // cb的参与者数量等于5时,才继续往后执行  
                System.out.println(Thread.currentThread().getName() + " continued.");  
            } catch (BrokenBarrierException e) {  
                e.printStackTrace();  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
    }  
}  
运行结果:  
  
Thread-1 wait for CyclicBarrier.  
Thread-2 wait for CyclicBarrier.  
Thread-3 wait for CyclicBarrier.  
Thread-4 wait for CyclicBarrier.  
Thread-0 wait for CyclicBarrier.  
CyclicBarrier's parties is: 5  
Thread-0 continued.  
Thread-4 continued.  
Thread-2 continued.  
Thread-3 continued.  
Thread-1 continued.  

Semaphore

我们以一个停车场运作为例来说明信号量的作用。假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了三辆车,看门人允许其中它们进入,然后放下车拦。以后来的车必须在入口等待,直到停车场中有车辆离开。这时,如果有一辆车离开停车场,看门人得知后,打开车拦,放入一辆,如果又离开一辆,则又可以放入一辆,如此往复。

在这个停车场系统中,车位是公共资源,每辆车好比一个线程,看门人起的就是信号量的作用。信号量是一个非负整数,表示了当前公共资源的可用数目(在上面的例子中可以用空闲的停车位类比信号量),当一个线程要使用公共资源时(在上面的例子中可以用车辆类比线程),首先要查看信号量,如果信号量的值大于1,则将其减1,然后去占有公共资源。如果信号量的值为0,则线程会将自己阻塞,直到有其它线程释放公共资源

在信号量上我们定义两种操作: acquire(获取) 和
release(释放)。当一个线程调用acquire操作时,它要么通过成功获取信号量(信号量减1),要么一直等下去,直到有线程释放信号量,或超时。release(释放)实际上会将信号量的值加1,然后唤醒等待的线程。

信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。

源码解析

在Java的并发包中,Semaphore类表示信号量。Semaphore内部主要通过AQS(AbstractQueuedSynchronizer)实现线程的管理。Semaphore有两个构造函数,
参数permits表示许可数 ,它最后传递给了AQS的state值。线程在运行时首先获取许可, 如果成功,许可数就减1
,线程运行,当线程运行结束就释放许可, 许可数就加1
。如果许可数为0,则获取失败,线程位于AQS的等待队列中,它会被其它释放许可的线程唤醒。在创建Semaphore对象的时候还可以指定它的公平性。一般常用非公平的信号量,非公平信号量是指在获取许可时先尝试获取许可,而不必关心是否已有需要获取许可的线程位于等待队列中,如果获取失败,才会入列。而公平的信号量在获取许可时首先要查看等待队列中是否已有线程,如果有则入列。

构造函数

//非公平的构造函数  
public Semaphore(int permits) {  
    sync = new NonfairSync(permits);  
}  
  
//通过fair参数决定公平性  
public Semaphore(int permits, boolean fair) {  
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);  
}   
  

acquire()

public void acquire() throws InterruptedException {  
    sync.acquireSharedInterruptibly(1);  
}  
  
public final void acquireSharedInterruptibly(int arg)  
        throws InterruptedException {  
    if (Thread.interrupted())  
        throw new InterruptedException();  
    if (tryAcquireShared(arg) < 0)  
        doAcquireSharedInterruptibly(arg);  
}  
  • 调用tryAcquireShared()方法尝试获取信号。
  • 如果没有可用信号,将当前线程加入等待队列并挂起

tryAcquireShared
会调用对应公平或者非公平同步器的方法,xxTAcquireShared下面是非公平的,公平的方法就多了一个hasQueuedPredecessors方法的逻辑

NonfairSync.tryAcquireShared()

final int nonfairTryAcquireShared(int acquires) {  
    for (;;) {  
        int available = getState();  
        int remaining = available - acquires; //剩余许可数  
        if (remaining < 0 ||  
            compareAndSetState(available, remaining))   
            return remaining;  
    }  
}  

可以看出,如果remaining <0
即获取许可后,许可数小于0,则获取失败,在doAcquireSharedInterruptibly方法中线程会将自身阻塞,然后入列。可以看到,非公平锁对于信号的获取是直接使用CAS进行尝试的。

FairSync.tryAcquireShared()

protected int tryAcquireShared(int acquires) {  
            for (;;) {  
                if (hasQueuedPredecessors())  
                    return -1;  
                int available = getState();  
                int remaining = available - acquires;  
                if (remaining < 0 ||  
                    compareAndSetState(available, remaining))  
                    return remaining;  
            }  
        }  
  
  • 先调用hasQueuedPredecessors()方法,判断队列中是否有等待线程。如果有,直接返回-1,表示没有可用信号

  • 队列中没有等待线程,再使用CAS尝试更新state,获取信号

doAcquireSharedInterruptibly()

private void doAcquireSharedInterruptibly(int arg)  
        throws InterruptedException {  
        final Node node = addWaiter(Node.SHARED);   // 1  
        boolean failed = true;  
        try {  
            for (;;) {  
                final Node p = node.predecessor();     
                if (p == head) {      // 2  
                    int r = tryAcquireShared(arg);  
                    if (r >= 0) {  
                        setHeadAndPropagate(node, r);  
                        p.next = null; // help GC  
                        failed = false;  
                        return;  
                    }  
                }  
                if (shouldParkAfterFailedAcquire(p, node) &&     // 3  
                    parkAndCheckInterrupt())  
                    throw new InterruptedException();  
            }  
        } finally {  
            if (failed)  
                cancelAcquire(node);     
        }  
    }  
  
  1. 封装一个Node节点,加入队列尾部
  2. 在无限循环中,如果当前节点是头节点,就尝试获取信号
  3. 不是头节点,在经过节点状态判断后,挂起当前线程

release()释放信号

public final boolean releaseShared(int arg) {  
        if (tryReleaseShared(arg)) {    // 1  
            doReleaseShared();  // 2  
            return true;  
        }  
        return false;  
    }  
  1. cas更新state加一
  2. 唤醒等待队列头节点线程

举个栗子

public static void main(String[] args) {  
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(10, 10,  
                0L, TimeUnit.MILLISECONDS,  
                new LinkedBlockingQueue<Runnable>(10));  
        //信号总数为5  
        Semaphore semaphore = new Semaphore(5);  
        //运行10个线程  
        for (int i = 0; i < 10; i++) {  
            threadPool.execute(new Runnable() {  
                  
                @Override  
                public void run() {  
                    try {  
                        //获取信号  
                        semaphore.acquire();     
                        System.out.println(Thread.currentThread().getName() + "获得了信号量,时间为" + System.currentTimeMillis());  
                        //阻塞2秒,测试效果  
                        Thread.sleep(2000);  
                        System.out.println(Thread.currentThread().getName() + "释放了信号量,时间为" + System.currentTimeMillis());  
                    } catch (InterruptedException e) {  
                        e.printStackTrace();  
                    } finally {  
                        //释放信号  
                        semaphore.release();  
                    }  
                  
                }  
            });  
        }  
        threadPool.shutdown();  
    }  
  
pool-1-thread-2获得了信号量,时间为1550584196125  
pool-1-thread-1获得了信号量,时间为1550584196125  
pool-1-thread-3获得了信号量,时间为1550584196125  
pool-1-thread-4获得了信号量,时间为1550584196126  
pool-1-thread-5获得了信号量,时间为1550584196127  
pool-1-thread-2释放了信号量,时间为1550584198126  
pool-1-thread-3释放了信号量,时间为1550584198126  
pool-1-thread-4释放了信号量,时间为1550584198126  
pool-1-thread-6获得了信号量,时间为1550584198126  
pool-1-thread-9获得了信号量,时间为1550584198126  
pool-1-thread-8获得了信号量,时间为1550584198126  
pool-1-thread-1释放了信号量,时间为1550584198126  
pool-1-thread-10获得了信号量,时间为1550584198126  
pool-1-thread-5释放了信号量,时间为1550584198127  
pool-1-thread-7获得了信号量,时间为1550584198127  
pool-1-thread-6释放了信号量,时间为1550584200126  
pool-1-thread-8释放了信号量,时间为1550584200126  
pool-1-thread-10释放了信号量,时间为1550584200126  
pool-1-thread-9释放了信号量,时间为1550584200126  
pool-1-thread-7释放了信号量,时间为1550584200127  

线程和进程

概述

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位.

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源.

一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行.

相对进程而言,线程是一个更加接近于执行体的概念,它可以与同进程中的其他线程共享数据,但拥有自己的栈空间,拥有独立的执行序列。

在串行程序基础上引入线程和进程是为了提高程序的并发度,从而提高程序运行效率和响应时间。

区别

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

  1. 简而言之,一个程序至少有一个进程,一个进程至少有一个线程.
  2. 线程的划分尺度小于进程,使得多线程程序的并发性高。
  3. 另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
  4. 线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。 但是线程不能够独立执行, 必须依存在应用程序中,由应用程序提供多个线程执行控制。
  5. 从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。 这就是进程和线程的重要区别。

多线程和多进程

对比维度 多进程 多线程 总结
数据共享、同步 数据共享复杂,需要用IPC;数据是分开的,同步简单 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU利用率低 占用内存少,切换简单,CPU利用率高 线程占优
创建销毁、切换 创建销毁、切换复杂,速度慢 创建销毁、切换简单,速度很快 线程占优
编程、调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会互相影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布式 进程占优

使用进程和线程一般根据以上的不同情况进行不同的选择,一般以业务逻辑划分进程,内部再细分线程

进程间通信

进程间通信的目的

  • 数据传输
    一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几M字节之间

  • 共享数据
    多个进程想要操作共享数据,一个进程对共享数据

  • 通知事
    一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

  • 资源共享
    多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。

  • 进程控制
    有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

linux进程间通信的方式

管道(pipe)

  • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
  • 只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程);
  • 单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在与内存中。
  • 数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。

管道

常见的Linux命令 “|” 其实就是匿名管道,表示把一个进程的输出传输到另外一个进程,如:

echo "Happyjava" | awk -F 'j' '{print $2}'  
# 输出 ava  
  

—|—

管道的实质:

  • 管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据,管道一端的进程顺序的将数据写入缓冲区,另一端的进程则顺序的读出数据。
  • 该缓冲区可以看做是一个循环队列,读和写的位置都是自动增长的,不能随意改变,一个数据只能被读一次,读出来以后在缓冲区就不复存在了。
  • 当缓冲区读空或者写满时,有一定的规则控制相应的读进程或者写进程进入等待队列,当空的缓冲区有新数据写入或者满的缓冲区有数据读出来时,就唤醒等待队列中的进程继续读写。

管道的局限:
管道的主要局限性正体现在它的特点上:

  • 只支持单向数据流;
  • 只能用于具有亲缘关系的进程之间;
  • 没有名字;
  • 管道的缓冲区是有限的(管道制存在于内存中,在管道创建时,为缓冲区分配一个页面大小);
  • 管道所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令、或记录)等等;

有名管道(FIFO)

匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO)。
有名管道不同于匿名管道之处在于它提供了一个路径名与之关联, 以有名管道的文件形式存在于文件系统中 ,这样,
即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信
,因此,通过有名管道不相关的进程也能交换数据。值的注意的是,有名管道严格遵循 先进先出(first in first out)
,对匿名管道及有名管道的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。
有名管道的名字存在于文件系统中,内容存放在内存中。

匿名管道和有名管道总结:
(1)管道是特殊类型的文件,在满足先入先出的原则条件下可以进行读写,但不能进行定位读写。
(2)匿名管道是单向的,只能在有亲缘关系的进程间通信;有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
(3) 无名管道阻塞问题:
无名管道无需显示打开,创建时直接返回文件描述符,在读写时需要确定对方的存在,否则将退出。如果当前进程向无名管道的一端写数据,必须确定另一端有某一进程。如果写入无名管道的数据超过其最大值,写操作将阻塞,如果管道中没有数据,读操作将阻塞,如果管道发现另一端断开,将自动退出。
(4) 有名管道阻塞问题:
有名管道在打开时需要确实对方的存在,否则将阻塞。即以读方式打开某管道,在此之前必须一个进程以写方式打开管道,否则阻塞。此外,可以以读写(O_RDWR)模式打开有名管道,即当前进程读,当前进程写,不会阻塞。

信号( Signal

  • 信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,直到该进程回复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。

信号来源

信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,,信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:

  • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
  • 软件终止:终止进程信号、其他进程调用kill函数、软件异常产生信号。

信号生命周期和处理流程

  1. 信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;

  2. 操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号。

  3. 目的进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后在回复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度。

消息队列( Message

注意,此消息队列不是我们常用的MQ,如kafka,rabbitmq,rocketmq等。

  • 消息队列是存放在内存中的消息链表,每个消息队列由消息队列标识符表示。
  • 与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。
  • 另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达

消息队列特点总结:

  1. 消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
  2. 消息队列允许一个或多个进程向它写入与读取消息.
  3. 管道和消息队列的通信数据都是先进先出的原则。
  4. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
  5. 消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺。
  6. 目前主要有两种类型的消息队列:POSIX消息队列以及System V消息队列,系统V消息队列目前被大量使用。系统V消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。

共享内存 (share memory)

  • 使得多个进程可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
  • 为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
  • 由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥。

共享内存

信号量( semaphore

信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。
为了获得共享资源,进程需要执行下列操作:
(1) 创建一个信号量 :这要求调用者指定初始值,对于二值信号量来说,它通常是1,也可是0。
(2) 等待一个信号量 :该操作会测试这个信号量的值,如果小于0,就阻塞。也称为P操作。
(3) 挂出一个信号量 :该操作将信号量的值加1,也称为V操作。

为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。Linux环境中,有三种类型:
Posix(可移植性操作系统接口)有名信号量(使用Posix
IPC名字标识)
Posix基于内存的信号量(存放在共享内存区中)System V信号量(在内核中维护)
。这三种信号量都可用于进程间或线程间的同步。

两个进程使用一个二值信号量

两个进程所以用一个Posix有名二值信号量

一个进程两个线程共享基于内存的信号量

信号量与普通整型变量的区别:

  1. 信号量是非负整型变量,除了初始化之外,它只能通过两个标准原子操作:wait(semap) , signal(semap) ; 来进行访问;
  2. 操作也被成为PV原语(P来源于荷兰语proberen”测试”,V来源于荷兰语verhogen”增加”,P表示通过的意思,V表示释放的意思),而普通整型变量则可以在任何语句块中被访问;

信号量与互斥量之间的区别:

  1. 互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。

互斥: 是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步: 是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源

  1. 互斥量值只能为0/1,信号量值可以为非负整数。
    也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。

  2. 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

套接字( socket

套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。

套接字是支持TCP/IP的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

套接字特性
套接字的特性由3个属性确定,它们分别是:域、端口号、协议类型。

  1. 套接字的域**
    它指定套接字通信中使用的网络介质,最常见的套接字域有两种:
    一是AF_INET,它指的是Internet网络。
    当客户使用套接字进行跨网络的连接时,它就需要用到服务器计算机的IP地址和端口来指定一台联网机器上的某个特定服务,所以在使用socket作为通信的终点,服务器应用程序必须在开始通信之前绑定一个端口,服务器在指定的端口等待客户的连接。
    另一个域AF_UNIX,表示UNIX文件系统, 它就是文件输入/输出,而它的地址就是文件名。

  2. 套接字的端口号
    每一个基于TCP/IP网络通讯的程序(进程)都被赋予了唯一的端口和端口号,端口是一个信息缓冲区,用于保留Socket中的输入/输出信息,端口号是一个16位无符号整数,范围是0-65535,以区别主机上的每一个程序(端口号就像房屋中的房间号),低于256的端口号保留给标准应用程序,比如pop3的端口号就是110,每一个套接字都组合进了IP地址、端口,这样形成的整体就可以区别每一个套接字。

  3. 套接字协议类型
    因特网提供三种通信机制,
    一是流套接字,
    流套接字在域中通过TCP/IP连接实现,同时也是AF_UNIX中常用的套接字类型。流套接字提供的是一个有序、可靠、双向字节流的连接,因此发送的数据可以确保不会丢失、重复或乱序到达,而且它还有一定的出错后重新发送的机制。
    二个是数据报套接字,
    它不需要建立连接和维持一个连接,它们在域中通常是通过UDP/IP协议实现的。它对可以发送的数据的长度有限制,数据报作为一个单独的网络消息被传输,它可能会丢失、复制或错乱到达,UDP不是一个可靠的协议,但是它的速度比较高,因为它并一需要总是要建立和维持一个连接。
    三是原始套接字, 原始套接字允许对较低层次的协议直接访问,比如IP、
    ICMP协议,它常用于检验新的协议实现,或者访问现有服务中配置的新设备,因为RAW
    SOCKET可以自如地控制Windows下的多种协议,能够对网络底层的传输机制进行控制,所以可以应用原始套接字来操纵网络层和传输层应用。比如,我们可以通过RAW
    SOCKET来接收发向本机的ICMP、IGMP协议包,或者接收TCP/IP栈不能够处理的IP包,也可以用来发送一些自定包头或自定协议的IP包。网络监听技术很大程度上依赖于SOCKET_RAW。

原始套接字与标准套接字的区别在于:

原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送数据必须使用原始套接字。

服务器端

  1. 首先服务器应用程序用系统调用socket来创建一个套接字,它是系统分配给该服务器进程的类似文件描述符的资源,它不能与其他的进程共享。

  2. 然后,服务器进程会给套接字起个名字,我们使用系统调用bind来给套接字命名。然后服务器进程就开始等待客户连接到这个套接字。

  3. 接下来,系统调用listen来创建一个队列并将其用于存放来自客户的进入连接。

  4. 最后,服务器通过系统调用accept来接受客户的连接。它会创建一个与原有的命名套接不同的新套接字,这个套接字只用于与这个特定客户端进行通信,而命名套接字(即原先的套接字)则被保留下来继续处理来自其他客户的连接(建立客户端和服务端的用于通信的流,进行通信)。

客户端

  1. 客户应用程序首先调用socket来创建一个未命名的套接字,然后将服务器的命名套接字作为一个地址来调用connect与服务器建立连接。
  2. 一旦连接建立,我们就可以像使用底层的文件描述符那样用套接字来实现双向数据的通信(通过流进行数据传输)。

总结

各种通信方式的比较和优缺点

  1. 管道:速度慢,容量有限,只有父子进程能通讯
  2. FIFO:任何进程间都能通讯,但速度慢
  3. 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
  4. 信号量:不能传递复杂消息,只能用来同步
  5. 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存

死锁

所谓死锁,是指多个进程循环等待它方占有的资源而无限期地僵持下去的局面。

死锁的产生

如果在计算机系统中同时具备下面四个必要条件时,那麽会发生死锁。换句话说,只要下面四个条件有一个不具备,系统就不会出现死锁。

  1. 互斥条件。即某个资源在一段时间内只能由一个进程占有,不能同时被两个或两个以上的进程占有。这种独占资源如CD-ROM驱动器,打印机等等,必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。如独木桥就是一种独占资源,两方的人不能同时过桥。

  2. 不可抢占条件。进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。如过独木桥的人不能强迫对方后退,也不能非法地将对方推下桥,必须是桥上的人自己过桥后空出桥面(即主动释放占有资源),对方的人才能过桥。

  3. 占有且申请条件。进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。还以过独木桥为例,甲乙两人在桥上相遇。甲走过一段桥面(即占有了一些资源),还需要走其余的桥面(申请新的资源),但那部分桥面被乙占有(乙走过一段桥面)。甲过不去,前进不能,又不后退;乙也处于同样的状况。

  4. 循环等待条件。存在一个进程等待序列{P1,P2,…,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,……,而Pn等待P1所占有的的某一资源,形成一个进程循环等待环。就像前面的过独木桥问题,甲等待乙占有的桥面,而乙又等待甲占有的桥面,从而彼此循环等待。

上面我们提到的这四个条件在死锁时会同时发生。也就是说,只要有一个必要条件不满足,则死锁就可以排除。

死锁的预防

前面介绍了死锁发生时的四个必要条件,只要破坏这四个必要条件中的任意一个条件,死锁就不会发生。这就为我们解决死锁问题提供了可能。一般地,解决死锁的方法分为死锁的预防,避免,检测与恢复三种(注意:死锁的检测与恢复是一个方法)。我们将在下面分别加以介绍。

死锁的预防是保证系统不进入死锁状态的一种策略。它的基本思想是要求进程申请资源时遵循某种协议,从而打破产生死锁的四个必要条件中的一个或几个,保证系统不会进入死锁状态。

  1. 打破互斥条件。即允许进程同时访问某些资源。但是,有的资源是不允许被同时访问的,像打印机等等,这是由资源本身的属性所决定的。所以,这种办法并无实用价值。
  2. 打破不可抢占条件。即允许进程强行从占有者那里夺取某些资源。就是说,当一个进程已占有了某些资源,它又申请新的资源,但不能立即被满足时,它必须释放所占有的全部资源,以后再重新申请。它所释放的资源可以分配给其它进程。这就相当于该进程占有的资源被隐蔽地强占了。这种预防死锁的方法实现起来困难,会降低系统性能。
  3. 打破占有且申请条件。可以实行资源预先分配策略。即进程在运行前一次性地向系统申请它所需要的全部资源。如果某个进程所需的全部资源得不到满足,则不分配任何资源,此进程暂不运行。只有当系统能够满足当前进程的全部资源需求时,才一次性地将所申请的资源全部分配给该进程。由于运行的进程已占有了它所需的全部资源,所以不会发生占有资源又申请资源的现象,因此不会发生死锁。但是,这种策略也有如下缺点:
  • 在许多情况下,一个进程在执行之前不可能知道它所需要的全部资源。这是由于进程在执行时是动态的,不可预测的;
  • 资源利用率低。无论所分资源何时用到,一个进程只有在占有所需的全部资源后才能执行。即使有些资源最后才被该进程用到一次,但该进程在生存期间却一直占有它们,造成长期占着不用的状况。这显然是一种极大的资源浪费;
  • 降低了进程的并发性。因为资源有限,又加上存在浪费,能分配到所需全部资源的进程个数就必然少了。
  1. 打破循环等待条件,实行资源有序分配策略。采用这种策略,即把资源事先分类编号,按号分配,使进程在申请,占用资源时不会形成环路。所有进程对资源的请求必须严格按资源序号递增的顺序提出。进程占用了小号资源,才能申请大号资源,就不会产生环路,从而预防了死锁。这种策略与前面的策略相比,资源的利用率和系统吞吐量都有很大提高,但是也存在以下缺点:
  • 限制了进程对资源的请求,同时给系统中所有资源合理编号也是件困难事,并增加了系统开销;
  • 为了遵循按编号申请的次序,暂不使用的资源也需要提前申请,从而增加了进程对资源的占用时间。

死锁的避免

上面我们讲到的死锁预防是排除死锁的静态策略,它使产生死锁的四个必要条件不能同时具备,从而对进程申请资源的活动加以限制,以保证死锁不会发生。下面我们介绍排除死锁的动态策略–死锁的避免,它不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态地检查,并根据检查结果决定是否进行资源分配。就是说,在资源分配过程中若预测有发生死锁的可能性,则加以避免。这种方法的关键是确定资源分配的安全性。

  1. 安全序列

我们首先引入安全序列的定义:所谓系统是安全的,是指系统中的所有进程能够按照某一种次序分配资源,并且依次地运行完毕,这种进程序列{P1,P2,…,Pn}就是安全序列。如果存在这样一个安全序列,则系统是安全的;如果系统不存在这样一个安全序列,则系统是不安全的。

安全序列{P1,P2,…,Pn}是这样组成的:若对于每一个进程Pi,它需要的附加资源可以被系统中当前可用资源加上所有进程Pj当前占有资源之和所满足,则{P1,P2,…,Pn}为一个安全序列,这时系统处于安全状态,不会进入死锁状态。

虽然存在安全序列时一定不会有死锁发生,但是系统进入不安全状态(四个死锁的必要条件同时发生)也未必会产生死锁。当然,产生死锁后,系统一定处于不安全状态。

  1. 银行家算法

这是一个著名的避免死锁的算法,是由Dijstra首先提出来并加以解决的。

当一个进程申请使用资源的时候,银行家算法通过先 试探
分配给该进程资源,然后通过安全性算法判断分配后的系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待。

银行家算法

死锁的检查与恢复

死锁的检查

检查死锁的办法就是检查系统中由进程和资源构成的有向图是否构成一个或多个环路,若是,则存在死锁,否则不存在。
由于死锁是系统中的恶性小概率事件,死锁检测程序的多次执行往往都不会调用一次死锁解除程序,而这却增加了系统开销,因此在设计操作系统时需要权衡检测精度与时间开销。

死锁的恢复

一旦在死锁检测时发现了死锁,就要消除死锁,使系统从死锁状态中恢复过来。

  1. 最简单,最常用的方法就是进行系统的重新启动,不过这种方法代价很大,它意味着在这之前所有的进程已经完成的计算工作都将付之东流,包括参与死锁的那些进程,以及未参与死锁的进程。
  2. 撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。这时又分两种情况:一次性撤消参与死锁的全部进程,剥夺全部资源;或者逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。一般来说,选择逐步撤消的进程时要按照一定的原则进行,目的是撤消那些代价最小的进程,比如按进程的优先级确定进程的代价;考虑进程运行时的代价和与此进程相关的外部作业的代价等因素。

此外,还有进程回退策略,即让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行,以求再次执行时不再发生死锁。虽然这是个较理想的办法,但是操作起来系统开销极大,要有堆栈这样的机构记录进程的每一步变化,以便今后的回退,有时这是无法做到的。

网络请求方式通常分为两种,分别是HTTP请求和HTTPS请求,其中HTTP的传输属于明文传输,在传输的过程中容易被人截取并且偷窥其中的内容,而HTTPS是一种在HTTP的基础上加了SSL/TLS层(安全套接层)的安全的超文本传输协议,其传输的内容是通过加密得到的,所以说是一种安全的传输。

说到加密算法,先来了解一下两种常用的加密方式,分别是对称加密和非对称加密:

1.对称加密:加密使用的秘钥和解密使用的秘钥是相同的,也就是说加密和解密都使用同一个秘钥,加密算法是公开的,秘钥是加密者和解密者绝对保密的。

2.非对称加密:加密使用的秘钥和解密使用的秘钥是不相同的,HTTPS在数字证书验证的时候,采用的RSA密码体制就是一种非对称加密。

RSA是一种公钥秘钥密码体制,现在使用非常广泛,这个密码体制分为三部分,公钥、私钥、加密算法,其中公钥和加密算法是公布的,私钥是自己保密的。这种机制最大的特点是,通过公钥加密的密文只有对应的私钥才能解密,同样通过私钥加密的密文也只有对应的公钥才能解密。下面我们将会讲到HTTPS是如何通过RSA这种密码体制去验证身份的。

首先了解一下数字证书,它有点像身份证,是由权威的CA机构颁发的,证书的主要内容有:公钥(Public Key)、ISSUER(证书的发布机构)、Subject(证书持有者)、证书有效期、签名算法、指纹及指纹算法。

下面csdn博客的CA证书内容:

图片

可以看到公钥是一串很长的2048 Bits的字符串,同时也可以看到<使用者>的内容包含了csdn.net网址,这个网址是CSDN唯一拥有的,后面验证链接url是否正确的时候用到,还有颁发者、有效期、签名哈希算法等等。当然还有指纹及指纹算法等其他内容,我们滚动到下面看看另外一个截图

图片

上面是CSDN网站CA证书,颁发者是GeoTrust,它就是权威的CA机构之一。到这里特别说明一下,CA机构除了给别人颁发证书以外,它也有自己的证书,为了区分我们称它为根证书,根证书也有自己的公钥和私钥,我们称之为根公钥和根私钥。然后根公钥和加密算法是向外公布的,而根私钥是机构自己绝对保密的。这个根证书在验证证书的过程中起着核心的作用。

指纹是什么?指纹是一个证书的签名,是通过指纹算法sha1计算出来的一个hash值,是用来验证证书内容有没有被篡改的。证书在发布之前,CA机构会把所颁发证书的内容用自己的根私钥通过指纹算法计算得到一个hash值,这个hash值只有对应的根公钥才能解密,所以在验证证书的时候,我们通过同样的指纹算法将证书内容通过计算得到另一个hash值,如果这个hash值跟证书上的签名解析出来的hash值相同,就代表证书没有被篡改过。

下面基于一个简单的图例,去分析整个HTTPS的数字证书验证过程:

图示如下:

图片

假设这是一个浏览器的HTTPS请求

一:首先浏览器通过URL网址去请求某个后台服务器,后台接收到请求后,就会给浏览器发送一个自己的CA数字证书。

二:浏览器接收到数字证书以后,就要开始进行验证工作了。首先从证书的内容中获取证书的颁发机构,然后从浏览器系统中去寻找此颁发机构是否为浏览器的信任机构。这里解析一下,世界上就几个权威的CA机构,这几个机构的信息都是预先嵌入到我们的浏览器系统中的。如果收到的一个数字证书但其颁发机构没有在我们浏览器系统中的,那么就会有警告提示无法确认证书的真假。如果是受信任的机构,那么就到下一步。

此时我们就可以从浏览器中找到CA机构的根公钥,用这个公钥去解析证书的签名得到一个hash值H1,上面提到过,这个签名是证书发布之前CA机构用自己的根私钥加密而成的,所以这里只能由根证书的根公钥去解密。然后用证书的指纹算法对证书的内容再进行hash计算得到另一个hash值H2,如果此时H1和H2是相等的,就代表证书没有被修改过。在证书没有被修改过的基础上,再检查证书上的使用者的URL(比如csdn.net)和我们请求的URL是否相等,如果相等,那么就可以证明当前浏览器连接的网址也是正确的,而不是一些钓鱼网之类的。

这里我们假设,如果浏览器的连接被某个钓鱼网截取了,钓鱼网也可以发一个自己的证书给浏览器,然后也可以通过证书没有被篡改的验证,但是在证书没有被篡改的情况下,通过对比证书上的URL和我们请求的URL,就可以发现这个证书的URL不是我们所要连接的网址,所以说钓鱼网也骗不了我们。

看到这里如果还不是很明白证书验证过程的话,我特别解析一下,我们知道CA机构有自己的根公钥和根私钥。在证书颁发之前,机构会用根私钥将这个证书内容加密得到一个签名,这个签名只能用对应的根公钥去解密。在客户端(浏览器)收到服务端发过来的证书以后,我们首先从浏览器中拿到机构的根公钥,用这个根公钥去解析证书的签名得到一个哈希值H1,这个H1代表证书的原始内容,假设这个证书上的签名是不法分子伪造的,但是伪造的签名不可能是根私钥加密生成的(因为根私钥是CA机构私有),所以根公钥也不可能去解密任何第三方生成的签名(加密内容只能由对应的公钥私钥解析)。然后我们再用同样的哈希算法对收证书内容进行计算得到哈希值H2,通过对比H1和H2是否相等就知道证书有没有被褚篡改过了。讲到这里,我们应该明白证书是否被篡改的验证机制了吧。

三:到这里,已经验证了证书是没有被篡改的并且确认连接的URL也是正确的,然后我们获取到了证书上的公钥。下一步有一个很重要的任务就是,如何将一个对称加密算法的秘钥安全地发给服务器。

首先随机生成一个字符串S作为我们的秘钥,然后通过证书公钥加密成密文,将密文发送给服务器。因为此密文是用公钥加密的,这是一个非对称加密,我们知道,这个密文只有私钥的持有者才能进行解密,所以说任何第三方截取到密文也是没用的,因为没有对应的私钥所以解析不出来。

一个关键步骤,发送密文的时候也会对消息内容进行签名操作。签名上面讲解过,就是对密文内容进行hash计算得到的一个hash值,将这个签名加密以后和消息内容一起发送出去。接收方收到消息以后,通过私钥解析出密文和签名的hash值,同时也会对接收的消息内容进行同样的计算得到另一个hash值,通过比对两个hash值是否相同来判断密文是否有修改过。

四:通过了上面的步骤以后,此时客户端和服务端都持有了对称加密算法的秘钥,然后兄弟两就可以愉快地安全通信了。

总结:数字证书的验证有两个重要的步骤,第一是验证数字证书没有被篡改以及连接的URL是否正确,第二是通过RSA机制的原理安全地将对称加密算法的秘钥发送给对方。这两步都完成以后,整个HTTPS的数字证书的验证就算是成功了。

JVM内存结构

首先,放张jvm架构图

JVM架构图

JVM内存结构主要有三大块: 堆内存方法区
。堆内存是JVM中最大的一块由年轻代和老年代组成,而年轻代内存又被分成三部分, Eden空间From Survivor空间
To Survivor空间 ,默认情况下年轻代按照 8:1:1 的比例来分配;

方法区存储类信息、常量、静态变量等数据,是线程共享的区域,为与Java堆区分,方法区还有一个别名Non-
Heap(非堆);栈又分为java虚拟机栈和本地方法栈主要用于方法的执行。

Java
虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。

  • 线程私有 :程序计数器、虚拟机栈、本地方法区
  • 线程共享 :堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)

程序计数器

程序计数寄存器( Program Counter Register ),Register 的命名源于 CPU
的寄存器,寄存器存储指令相关的线程信息,CPU 只有把数据装载到寄存器才能够运行。

这里,并非是广义上所指的物理寄存器,叫程序计数器(或PC计数器或指令计数器)会更加贴切,并且也不容易引起一些不必要的误会。 JVM 中的 PC
寄存器是对物理 PC 寄存器的一种抽象模拟

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的 行号指示器

作用

PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。

PC寄存器

(分析:进入class文件所在目录,执行 javap -v xx.class 反解析(或者通过 IDEA 插件 Jclasslib
直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。)

概述

通过下面两个问题,理解下PC计数器

  • 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

  • PC寄存器为什么会被设定为线程私有的?

多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。

相关总结如下:

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的 当前方法 。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 它是唯一一个在 JVM 规范中没有规定任何OutOfMemoryError 情况的区域

虚拟机栈

概述

Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java
栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java
方法调用,是线程私有的,生命周期和线程一致。

作用 :主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着 入栈 (进栈/压栈),方法执行结束 出栈
  • 栈不存在垃圾回收问题

栈中可能出现的异常

Java 虚拟机规范允许 Java虚拟机栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
  • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常

可以通过参数-Xss来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

官方提供的参考工具,可查一些参数和操作:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html#BGBCIEFC

栈的存储单位

栈中存储什么?

  • 每个线程都有自己的栈,栈中的数据都是以 栈帧(Stack Frame)的格式存在
  • 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

栈运行原理

  • JVM 直接对 Java 栈的操作只有两个,对栈帧的 压栈出栈 ,遵循“先进后出/后进先出”原则
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧( 栈顶栈帧 )是有效的,这个栈帧被称为 当前栈帧 (Current Frame),与当前栈帧对应的方法就是 当前方法 (Current Method),定义这个方法的类就是 当前类 (Current Class)
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
  • Java 方法有两种返回函数的方式, 一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出

IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况

方法调用栈

栈帧的内部结构

每个 栈帧 (Stack Frame)中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或称为表达式栈)
  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用
  • 方法返回地址(Return Address):方法正常退出或异常退出的地址
  • 一些附加信息

栈解析

局部变量表

  • 局部变量表也被称为局部变量数组或者本地变量表
  • 是一组变量值存储空间, 主要用于存储方法参数和定义在方法体内的局部变量 ,包括编译器可知的各种 Java 虚拟机 基本数据类型 (boolean、byte、char、short、int、float、long、double)、 对象引用 (reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此 不存在数据安全问题
  • 局部变量表所需要的容量大小是编译期确定下来的 ,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的
  • 方法嵌套调用的次数由栈的大小决定。一般来说, 栈越大,方法嵌套调用次数越多 。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效 。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽 Slot
  • 局部变量表最基本的存储单元是 Slot(变量槽)
  • 在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
    • byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
    • long 和 double 则占据两个 Slot
  • JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会 按照顺序被复制 到局部变量表中的每一个 Slot 上
  • 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可 。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)
  • 如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
  • 栈帧中的局部变量表中的槽位是可以重用的 ,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而 达到节省资源的目的 。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)

槽

  • 在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

操作数栈

  • 每个独立的栈帧中除了包含局部变量表之外,还包含一个 后进先出 (Last-In-First-Out)的操作数栈,也可以称为 表达式栈 (Expression Stack)
  • 操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
概述
  • 操作数栈, 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来, 此时这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack 数据项中
  • 栈中的任何一个元素都可以是任意的 Java 数据类型
    • 32bit 的类型占用一个栈单位深度
    • 64bit 的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中 ,并更新 PC 寄存器中下一条需要执行的字节码指令
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
  • 另外,我们说 Java虚拟机的解释引擎是基于栈的执行引擎 ,其中的栈指的就是操作数栈
栈顶缓存(Top-of-stack-Cashing)

HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。寄存器是物理 CPU
中的组成部分之一,它同时也是 CPU
中非常重要的高速存储资源。一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU
寄存器数量是不同和不规律的。寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction
dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM
设计者们提出了栈顶缓存技术, 将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

动态链接(指向运行时常量池的方法引用)

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用 。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
  • 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为 符号引用 (Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

动态链接作用

JVM 是如何执行方法调用的

方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class
文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是 符号引用 ,而不是方法在实际运行时内存布局中的入口地址(
直接引用 )。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。

【这一块内容,除了方法调用,还包括解析、分派(静态分派、动态分派、单分派与多分派),这里先不介绍,后续再挖】

在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关

  • 静态链接 :当一个字节码文件被装载进 JVM 内部时,如果被调用的 目标方法在编译期可知 ,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接 :如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。
绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定: 早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时 ,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。
虚方法和非虚方法
  • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
  • 其他方法称为虚方法
虚方法表

在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM
采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。

每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。

方法返回地址(return address)

用来存放调用该方法的 PC 寄存器的值。

一个方法的结束,有两种方式

  • 正常执行完成
  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC
计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称 正常完成出口

一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定

在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int
类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void
的方法、实例初始化方法、类和接口的初始化方法使用。

  1. 在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称 异常完成出口

方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

本质上, 方法的退出就是当前栈帧出栈的过程
。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于: 通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。

本地方法栈

本地方法接口

简单的讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。

为什么要使用本地方法(Native Method)?

Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了

  • 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
  • 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
  • Sun’s Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类 java.lang.ThreadsetPriority() 的方法是用Java 实现的,但它实现调用的是该类的本地方法 setPrioruty(),该方法是C实现的,并被植入 JVM 内部。

本地方法栈(Native Method Stack)

  • Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
  • 本地方法栈也是线程私有的
  • 允许线程固定或者可动态扩展的内存大小
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个OutofMemoryError异常
  • 本地方法是使用 C 语言实现的
  • 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
  • 并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈
  • 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

栈是运行时的单位,而堆是存储的单位

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

堆内存

内存划分

对于大多数应用,Java 堆是 Java
虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。

为了进行高效的垃圾回收,虚拟机把堆内存 逻辑上 划分成三块区域(分代的唯一理由就是优化 GC 性能):

  • 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
  • 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
  • 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存

JVM堆内存划分

Java 虚拟机规范规定,Java
堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过
-Xmx-Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

年轻代 (Young Generation)

年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC 。年轻一代被分为三个部分——伊甸园( Eden
Memory
)和两个幸存区( Survivor Memory ,被称为from/to或s0/s1),默认比例是8:1:1

  • 大多数新创建的对象都位于 Eden 内存空间中
  • 当 Eden 空间被对象填充时,执行 Minor GC ,并将所有幸存者对象移动到一个幸存者空间中
  • Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
  • 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代

老年代(Old Generation)

旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major
GC),通常需要更长的时间。

大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝

Java8前后堆内存对比

元空间

不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。

虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。

所以元空间放在后边的方法区再说。

设置堆内存大小和 OOM

Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx-Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

我们通常会将 -Xmx-Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能

  • 默认情况下,初始堆内存大小为:电脑内存大小/64
  • 默认情况下,最大堆内存大小为:电脑内存大小/4

可以通过代码获取到我们的设置值,当然也可以模拟 OOM:

public static void main(String[] args) {  
  
  //返回 JVM 堆大小  
  long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;  
  //返回 JVM 堆的最大内存  
  long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;  
  
  System.out.println("-Xms : "+initalMemory + "M");  
  System.out.println("-Xmx : "+maxMemory + "M");  
  
  System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");  
  System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");  
}  

查看 JVM 堆内存分配

  1. 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小

  2. 默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置

* 新生代中的 **Eden** : **From Survivor** : **To Survivor** 的比例是 **8:1:1** ,可以通过 `-XX:SurvivorRatio` 来配置
  1. 若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄

此时 –XX:NewRatio-XX:SurvivorRatio 将会失效,而 JDK 8
是默认开启-XX:+UseAdaptiveSizePolicy

在 JDK 8中, 不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划

每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小

计算依据是 GC过程 中统计的 GC时间吞吐量内存占用量

java -XX:+PrintFlagsFinal -version | grep HeapSize  
    uintx ErgoHeapSizeLimit                         = 0                                   {product}  
    uintx HeapSizePerGCThread                       = 87241520                            {product}  
    uintx InitialHeapSize                          := 134217728                           {product}  
    uintx LargePageHeapSizeThreshold                = 134217728                           {product}  
    uintx MaxHeapSize                              := 2147483648                          {product}  
java version "1.8.0_211"  
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)  
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)  
$ jmap -heap 进程号  

对象在堆中的生命周期

  1. 在 JVM 内存模型的堆中,堆被划分为新生代和老年代
    * 新生代又被进一步划分为 Eden区Survivor区 ,Survivor 区由 From SurvivorTo Survivor 组成
  2. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区
    * 此时 JVM 会给对象定义一个 对象年轻计数器-XX:MaxTenuringThreshold
  3. 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
    * JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
    * 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
  4. 如果分配的对象超过了-XX:PetenureSizeThreshold,对象会 直接被分配到老年代

对象的分配过程

为对象分配内存是一件非常严谨和复杂的任务,JVM
的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC
执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new 的对象先放在伊甸园区,此区有大小限制
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区
  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区
  6. 什么时候才会去养老区呢? 默认是 15 次回收标记
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常

GC 垃圾回收简介

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾

TLAB

什么是 TLAB (Thread Local Allocation Buffer)?

  • 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为 快速分配策略
  • OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计

为什么要有 TLAB ?

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置
TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

堆是分配对象存储的唯一选择吗

随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
——《深入理解 Java 虚拟机》

逃逸分析

逃逸分析(Escape Analysis)* 是目前 Java 虚拟机中比较前沿的优化技术*。这是一种可以有效减少 Java
程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法**。通过逃逸分析,Java Hotspot
编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。

例如:

public static StringBuffer craeteStringBuffer(String s1, String s2) {  
   StringBuffer sb = new StringBuffer();  
   sb.append(s1);  
   sb.append(s2);  
   return sb;  
}  

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer
有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但是其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要 StringBuffer sb不逃出方法,可以这样写:

public static String createStringBuffer(String s1, String s2) {  
   StringBuffer sb = new StringBuffer();  
   sb.append(s1);  
   sb.append(s2);  
   return sb.toString();  
}  

不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法。

参数设置:

  • 在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析
  • 如果使用较早版本,可以通过-XX"+DoEscapeAnalysis显式开启

开发中使用局部变量,就不要在方法外定义。

使用逃逸分析,编译器可以对代码做优化:

  • 栈上分配 :将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • 同步省略 :如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 分离对象或标量替换 :有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器

JIT
编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递

代码优化之同步省略(消除)
  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能

  • 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做 同步省略,也叫锁消除

    public void keep() {
    Object keeper = new Object();
    synchronized(keeper) {
    System.out.println(keeper);
    }
    }

如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()方法中,并不会被其他线程所访问到,所以在
JIT编译阶段就会被优化掉。优化成:

public void keep() {  
  Object keeper = new Object();  
  System.out.println(keeper);  
}  
  
代码优化之标量替换

标量 (Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。

相对的,那些的还可以分解的数据叫做 聚合量 (Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM
不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是 标量替换

通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations
查看标量替换情况。

public static void main(String[] args) {  
   alloc();  
}  
  
private static void alloc() {  
   Point point = new Point(1,2);  
   System.out.println("point.x="+point.x+"; point.y="+point.y);  
}  
class Point{  
    private int x;  
    private int y;  
}  

以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point
对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

private static void alloc() {  
   int x = 1;  
   int y = 2;  
   System.out.println("point.x="+x+"; point.y="+y);  
}  
  
代码优化之栈上分配

我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给
GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM
通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

总结:

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

方法区

  • 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
  • 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
  • JVM 关闭后方法区即被释放

解惑

你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?

  • 方法区(method area)只是 JVM 规范中定义的一个概念 ,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而 永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间 取代了,永久代和元空间都可以理解为方法区的落地实现。
  • 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
  • Java7 中我们通过-XX:PermSize-xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize-XX:MaxMetaspaceSize 用来设置元空间参数
  • 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
  • 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出 OutOfMemoryError
  • JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)

所以对于方法区,Java8 之后的变化:

  • 移除了永久代(PermGen),替换为元空间(Metaspace);
  • 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
  • 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
  • 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

设置方法区内存的大小

JDK8 及以后:

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定,替代上述原有的两个参数
  • 默认值依赖于平台。Windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspacaSize 的值是 -1,即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize :设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

方法区内部结构

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
  • 这个类型的修饰符(public,abstract,final 的某个子集)
  • 这个类型直接接口的一个有序列表

域(Field)信息

  • JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
  • 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

方法(Method)信息

JVM 必须保存所有方法的

  • 方法名称
  • 方法的返回类型
  • 方法参数的数量和类型
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  • 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  • 异常表(abstract 和 native 方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,理解运行时常量池的话,我们先来说说字节码文件(Class
文件)中的常量池(常量池表)

常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool
Table),包含各种字面量和对类型、域和方法的符号引用。

为什么需要常量池?

一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java
中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。

如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool

![ Constant Pool](/images/ Constant Pool.jpg)

常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池

  • 在加载类和结构到虚拟机后,就会创建对应的运行时常量池
  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
  • 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于 Class 文件常量池的另一个重要特征是: 动态性 ,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern() 方法就是这样的
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。

方法区在 JDK6、7、8中的演进细节

只有 HotSpot 才有永久代的概念

jdk1.6及之前 有永久代,静态变量存放在永久代上
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中
  • HotSpot中字符串常量池保存哪里?永久代?方法区还是堆区**?
  1. 运行时常量池(Runtime Constant Pool)是虚拟机规范中是方法区的一部分,在加载类和结构到虚拟机后,就会创建对应的运行时常量池;而字符串常量池是这个过程中常量字符串的存放位置。所以从这个角度,字符串常量池属于虚拟机规范中的方法区,它是一个 逻辑上的概念 ;而堆区,永久代以及元空间是实际的存放位置。
  2. 不同的虚拟机对虚拟机的规范(比如方法区)是不一样的,只有 HotSpot 才有永久代的概念。
  3. HotSpot也是发展的,由于一些问题的存在,HotSpot考虑逐渐去永久代,对于不同版本的JDK, 实际的存储位置 是有差异的,具体看如下表格:
JDK版本 是否有永久代,字符串常量池放在哪里? 方法区逻辑上规范,由哪些实际的部分实现的?
jdk1.6及之前 有永久代,运行时常量池(包括字符串常量池),静态变量存放在永久代上 这个时期方法区在HotSpot中是由永久代来实现的,以至于
这个时期说方法区就是指永久代
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中; 这个时期方法区在HotSpot中由 永久代
(类型信息、字段、方法、常量)和 (字符串常量池、静态变量)共同实现
jdk1.8及之后 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中
这个时期方法区在HotSpot中由本地内存的 元空间 (类型信息、字段、方法、常量)和 (字符串常量池、静态变量)共同实现

移除永久代原因

http://openjdk.java.net/jeps/122

  • 为永久代设置空间大小是很难确定的。

在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web
工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现
OOM。而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制

  • 对永久代进行调优较困难

方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容: 常量池中废弃的常量和不再使用的类型

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final
的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

Java
虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot
虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading
-XX:+TraceClassUnLoading 查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader
的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

0%