知识犹如人体的血液一样宝贵。——高士其
其他精彩文章:20+公司面试题总结及补充
1.计算机网络
1.1 HTTPS和HTTP
HTTP是明文传输,不安全。HTTPS是加密之后的传输方式。
要弄清,先理解对称加密和非对称加密。
对称加密(Symmetric encryption)和非对称加密(Asymmetric encryption)
对称加密(常见算法为AES、DES、3DES等)
对称加密:在每次发送真实数据之前,服务器先生成一把密钥,然后先把密钥传输给客户端。之后服务器给客户端发送真实数据的时候,会用这把密钥对数据进行加密,客户端收到加密数据之后,用刚才收到的密钥进行解密。
如图:
这里只画了服务器传送数据给客户端的情况,暂时只考虑这个单向,其实是一样的。
但是这里有一个致命的问题,就是秘钥在传输过程中会是以明文方式传输的,在传输过程中可能被拦截。(这也是二战时期图灵能够破解德军军情的原因)
具体来说:假如服务器用明文的方式传输密钥给客户端,然后密钥被中间人给捕获了,那么在之后服务器和客户端的加密传输过程中,中间人也可以用他捕获的密钥进行解密。这样的话,加密的数据在中间人看来和明文没啥两样。
所以对称加密的问题出在如何把秘钥安全地给客户端。
非对称加密(常见算法为RSA、ECC等)
让客户端和服务器都拥有两把钥匙,一个用来加密(公钥),一个用来解密(私钥)。这两把钥匙一般都会用RSA Algorithm来生成。在通信之前不需要先同步秘钥,避免了在同步私钥过程中被黑客盗取信息的风险。
一把钥匙是公开的(全世界知道都没关系),我们称之为公钥;另一把钥匙则是保密的(只有自己本人才知道),我们称之为私钥。
虽然有一点违反常识,但是非对称加密能够做到:用公钥加密的原文,原公钥无法解密,只能用对应的私钥解密。
可以通过私钥计算出公钥,但是无法用公钥推导出私钥。
这且,用公钥加密的数据,只有对应的私钥才能解密;用私钥加密的数据,只有对应的公钥才能解密。
按照这个思路,服务器在给客户端传输数据的过程中,可以用客户端明文给他的公钥进行加密,然后客户端收到后,再用自己的私钥进行解密。客户端给服务器发送数据的时候也一样采取这样的方式。这样就能保持数据的安全传输了。
图解如下:
但是非对称加密在加密的时候速度比对称加密慢上百倍,所以直接用非对称加密的话效率很低。
因为对称加密不安全的主要原因是密钥无法安全交付给客户端,所以我们可以用非对称加密的方式传输加密过程中的密钥,之后我们就可以采取对称加密的方式来传输数据了。
具体做法:服务器用明文的方式给客户端发送自己的公钥,客户端收到公钥之后,会生成一把密钥(对称加密用的),然后用服务器的公钥对这把密钥进行加密,之后再把密钥传输给服务器,服务器收到之后进行解密,最后服务器就可以安全着得到这把密钥了,而客户端也有同样一把密钥,他们就可以进行对称加密了。
但是非对称加密也不能保证安全,举个例子:
服务器以明文的方式给客户端传输公钥的时候,中间人截取了这把属于服务器的公钥,并且把中间人自己的公钥冒充服务器的公钥传输给了客户端。
之后客户端就会用中间人的公钥来加密自己生成的密钥。然后把被加密的密钥传输给服务器,这个时候中间人又把密钥给截取了,中间人用自己的私钥对这把被加密的密钥进行解密,解密后中间人就可以获得这把密钥了。
最后中间人再对这把密钥用刚才服务器的公钥进行加密,再发给服务器。如图:
在这个过程中,中间人获取了对称加密中的密钥,在之后服务器和客户端的对称加密传输中,这些加密的数据对中间人来说,和明文没啥区别。
具体说来,非对称加密的不安全的原因主要是客户端不知道这把公钥是不是属于服务器的。
解决方法:数字证书。
数字证书
之所以非对称加密会不安全,是因为客户端不知道这把公钥是否是服务器的,因此,我们需要找到一种策略来证明这把公钥就是服务器的,而不是别人冒充的。
解决这个问题的方式就是使用数字证书,具体是这样的:
我们需要找到一个拥有公信力、大家都认可的认证中心(CA)。
服务器在给客户端传输公钥的过程中,会把公钥以及服务器的个人信息通过Hash算法生成信息摘要。
如图:
为了防止信息摘要被调换,CA会提供私钥给服务器,服务器会用这个CA给的私钥来加密得到数字签名。
如图:
在生成了数字签名之后,服务端会把所有信息合成在一起,成为数字证书。
客户端拿到证书之后,为了确认发送者确实是服务端,会利用CA给的公钥来对数字证书里面的数字签名进行解密来得到信息摘要,然后对数字证书里服务器的公钥以及个人信息进行Hash得到另外一份信息摘要,最后比较两者,相同,才能确认这份证书是服务端发送的,否则很可能被人动过手脚。
由此,可以保证服务器的公钥安全地交给了客户端。
CA的公钥如何拿给客户端?服务器如何获得CA私钥?
服务器一开始会向CA申请特定私钥。客户端很多浏览器都支持HTTPS方式,也都申请了证书。
更加形象的讲解内容可以参考什么是数字签名
数字签名
数字签名详解,参考这篇文章
SSL/TLS协议
SSL 即安全套接字层,它在 OSI 七层网络模型中处于第五层(网络层)
TLS 用于两个通信应用程序之间提供保密性和数据完整性。TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议组成,综合使用了对称加密、非对称加密、身份认证等许多密码学前沿技术。
TLS 在根本上使用对称加密和 非对称加密 两种形式。
HTTPS在使用SSL/TLS传输的过程中,先会用非对称加密,然后用对称加密,也就是所谓的混合加密。
实际上混合加密在上面,优化非对称加密传输的过程中已经有详细介绍了。因为非对称加密算法(比如RSA)计算非常慢,而对称加密算法(比如AES)计算相对非对称快上一百倍,所以先用非对称加密算法,先用非对称加密解决秘钥交换的问题,然后用随机数产生对称算法使用的会话密钥(session key)
,再用公钥加密
。对方拿到密文后用私钥解密
,取出会话密钥。这样,双方就实现了对称密钥的安全交换。
HTTP状态码?(1开头到5开头的各种典型状态码)
客户端的每一次请求,服务器都必须给出回应。回应包括 HTTP 状态码和数据两部分。
HTTP 状态码就是一个三位数,一共有五种取值可能:
- 1xx:指示信息——表示请求已经接收,继续处理,但是整个请求还没成功
- 2xx:成功——表示请求已被成功接收、理解、接受,已经成功处理了请求的状态代码
- 3xx:重定向——要完成请求,但是需要进一步操作(往往要再跳转一步)
- 4xx:客户端错误——请求有语法错误或请求无法实现
- 5xx:服务器错误——服务器未能实现合法的请求,表示服务器在尝试处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是请求的错误。
常见HTTP状态码及其作用:
200 OK:正常返回信息
400 Bad Request:客户端请求有语法错误,不能被服务器所理解
401 Unauthorized:请求未经授权,这个状态代码必须和WWW-Authenticate报头域一起使用
403 Forbidden:服务器收到请求,但拒绝提供服务(比如IP被禁了)
404 Note Found:请求资源不存在,eg.输入了错误的URL
500 Internal Server Error:服务器发生不可预期的错误
503 Server Unabaliable:服务器当前不能处理客户端的请求,一段时间后可能恢复正常
HTTP请求包内容(0.9, 1.0, 1.1, 2.0)
【HTTP的历史】
HTTP/0.9
1990年问世,那时的HTTP并没有作为正式的标准被建立,这时的HTTP其实含有HTTP/1.0之前版本的意思,那时候还有严重设计缺陷,只支持GET方法,不支持MIM类型,很快被HTTP/1.0取代。
并且协议还规定,服务器只能回应HTML格式的字符串,不能回应别的格式,当服务器发送完毕,就关闭TCP连接。
HTTP/1.0
HTTP正式作为标准被公布是在1996年的5月,版本被命名为HTTP/1.0,并记载于RFC1945。虽然说是初期标准,但该协议标准至今仍被使用在服务器端。
[特点]
1、任何格式的内容都可以发送。这使得互联网不仅可以传输文字,还能传输图像、视频、二进制文件。这为互联网的大发展奠定了基础。
2、除了GET命令,还引入了POST命令和HEAD命令,丰富了浏览器与服务器的互动手段。
3、HTTP请求和回应的格式也变了。除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。
4、其他的新增功能还包括状态码(status code)、多字符集支持、多部分发送(multi-part type)、权限(authorization)、缓存(cache)、内容编码(content encoding)等。
[不足]
HTTP/1.0 版的主要缺点是,每个TCP连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。TCP连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(slow start)。所以,HTTP 1.0版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。
更多关于HTTP/1.0的信息请见:https://tools.ietf.org/html/rfc1945
HTTP/1.1
1997年公布的HTTP/1.1是目前主流的HTTP协议版本。之前的标准是RFC2068,之后又发布了修订版RFC2616。
[特点]
1、引入了持久连接(persistent connection),即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送Connection: close,明确要求服务器关闭TCP连接
2、引入了管道机制(pipelining),即在同一个TCP连接里面,客户端可以同时发送多个请求。这样就进一步改进了HTTP协议的效率。举例来说,客户端需要请求两个资源。以前的做法是,在同一个TCP连接里面,先发送A请求,然后等待服务器做出回应,收到后再发出B请求。管道机制则是允许浏览器同时发出A请求和B请求,但是服务器还是按照顺序,先回应A请求,完成后再回应B请求。
3、将Content-length字段的作用进行扩充,即声明本次回应的数据长度(一个TCP连接现在可以传送多个回应,势必就要有一种机制,区分数据包是属于哪一个回应的)
4、采用分块传输编码,对于一些很耗时的动态操作,服务器需要等到所有操作完成,才能发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用”流模式”(stream)取代”缓存模式”(buffer)
5、1.1版还新增了许多动词方法:PUT、PATCH、HEAD、 OPTIONS、DELETE。另外,客户端请求的头信息新增了Host字段,用来指定服务器的域名
[不足]
虽然1.1版允许复用TCP连接,但是同一个TCP连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为”队头堵塞”(Head-of-line blocking)。为了避免这个问题,只有两种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入CSS代码、域名分片(domain sharding)等等。如果HTTP协议设计得更好一些,这些额外的工作是可以避免的。
HTTP/2
2015年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3
[特点]
1、HTTP/2 的头信息是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为”帧”(frame):头信息帧和数据帧。
2、HTTP/2 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了”队头堵塞”。
3、因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。
4、HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如Cookie和User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。
5、HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。
什么是HTTP的长连接和短连接?
短连接(HTTP/1.0中默认使用短连接):客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中 断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源(如JavaScript文件、图像 文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。
从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入这行代码:
Connection:keep-alive
在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客 户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时 间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。
实际上,HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。
一些可能有用的与HTTP有关的协议
在互联网中,任何协议都不会单独的完成信息交换,HTTP 也一样。虽然 HTTP 属于应用层的协议,但是它仍然需要其他层次协议的配合完成信息的交换,那么在完成一次 HTTP 请求和响应的过程中,需要哪些协议的配合呢?一起来看一下
CDN
CDN的全称是Content Delivery Network
,即内容分发网络
,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 是构建在现有网络基础之上的网络,它依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储
和分发技术
。
打比方说你要去亚马逊上买书,之前你只能通过购物网站购买后从美国发货过海关等重重关卡送到你的家里,现在在中国建立一个亚马逊分基地,你就不用通过美国进行邮寄,从中国就能把书尽快给你送到。
TCP/IP
TCP/IP 我们一般称之为协议簇,什么意思呢?就是 TCP/IP 协议簇中不仅仅只有 TCP 协议和 IP 协议,它是一系列网络通信协议的统称。而其中最核心的两个协议就是 TCP / IP 协议,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。
HTTP 使用 TCP 作为通信协议,这是因为 TCP 是一种可靠的协议,而可靠能保证数据不丢失。
DNS
计算机网络中的每个端系统都有一个 IP 地址存在,而把 IP 地址转换为便于人类记忆的协议就是 DNS 协议。
DNS 的全称是域名系统(Domain Name System,缩写:DNS)
,它作为将域名和 IP 地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。
URI/URL
我们上面提到,你可以通过输入 www.google.com 地址来访问谷歌的官网,那么这个地址有什么规定吗?我怎么输都可以?AAA.BBB.CCC 是不是也行?当然不是的,你输入的地址格式必须要满足 URI 的规范。
URI的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。
URL的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集。
URI 不仅包括 URL,还包括 URN(统一资源名称),它们之间的关系如下
HTTPS
之前讲了很多了,重点是HTTPS和HTTP的区别:
HTTPS 和 HTTP 有很大的不同在于 HTTPS 是以安全为目标的 HTTP 通道,在 HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性。HTTPS 在 HTTP 的基础上增加了 SSL 层,也就是说 HTTPS = HTTP + SSL
输入一个URL之后,整个请求过程是怎样的?
举例,访问地址为 http://www.someSchool.edu/someDepartment/home.index
一共六步
- DNS服务器会首先进行域名的映射,找到访问www.someSchool.edu所在的地址,然后HTTP 客户端进程在 80 端口发起一个到服务器 www.someSchool.edu 的 TCP 连接(80 端口是 HTTP 的默认端口)。在客户和服务器进程中都会有一个套接字与其相连。
- HTTP 客户端通过它的套接字向服务器发送一个 HTTP 请求报文。该报文中包含了路径 someDepartment/home.index 的资源,我们后面会详细讨论 HTTP 请求报文。
- HTTP 服务器通过它的套接字接受该报文,进行请求的解析工作,并从其存储器(RAM 或磁盘)中检索出对象 www.someSchool.edu/someDepartment/home.index,然后把检索出来的对象进行封装,封装到 HTTP 响应报文中,并通过套接字向客户进行发送。
- HTTP 服务器随即通知 TCP 断开 TCP 连接,实际上是需要等到客户接受完响应报文后才会断开 TCP 连接。
- HTTP 客户端接受完响应报文后,TCP 连接会关闭。HTTP 客户端从响应中提取出报文中是一个 HTML 响应文件,并检查该 HTML 文件,然后循环检查报文中其他内部对象。
- 检查完成后,HTTP 客户端会把对应的资源通过显示器呈现给用户。
这是个简单的例子,详细的例子可以参考这篇文章
GET和POST报文形式
实际上HTTP的响应报文结构都是一样的,GET和POST只是会体现在协议版本的地方不同而已。
分为请求行、响应头、响应正文几个部分。每次响应可以没有正文,但是必须有请求头。注意请求头后面会空出来一行,再开始响应正文。
如下图:
一个例子:
GET和POST的区别
可以从三个层面解释:
- HTTP报文层面:GET将请求信息放在URL中,POST放在报文体中。
- 数据库层面:GET符合幂等性 (对数据库的多次操作效果一样,PUT也是幂等的)和安全性 (操作不会改变数据库中的数据),POST不符合。
- 其他层面:GET可以被缓存、被存储,而POST不行; GET方式提交的数据大小有限制(因为浏览器对URL的长度有限制),而POST则没有此限制;服务器取值方式不一样。GET方式取值,如php可以使用$_GET来取得变量的值,而POST方式通过$_POST来获取变量的值。
- 安全性:使用 Get 的时候,参数会显示在地址栏上,而 Post 不会。所以,如果这些数据是中文数据而且是非敏感数据,那么使用 get;如果用户输入的数据不是中文字符而且包含敏感数据,那么还是使用 post为好。
除了GET和POST的其他报文
HEAD(请求获取由 Request-URI 所标识的资源的响应消息报头)
HEAD 方法与 GET 方法几乎是相同的,它们的区别在于 HEAD 方法只是请求消息报头,而不是完整的内容。对于 HEAD 请求的回应部分来说,它的 HTTP 头部中包含的信息与通过 GET 请求所得到的信息是相同的。利用这个方法,不必传输整个资源内容,就可以得到 Request-URI 所标识的资源的信息。这个方法通常被用于测试超链接的有效性,是否可以访问,以及最近是否更新。
所有方法总览:
方法 | 作用 |
---|---|
GET | 请求获取由 Request-URI 所标识的资源 请求参数在 请求行中 |
POST | 请求服务器接收在请求中封装的实体,并将其作为由 Request-Line 中的 Request-URI 所标识的资源的一部分请求参数在请求体中 |
HEAD | 请求获取由 Request-URI 所标识的资源的响应消息报头 |
PUT | 请求服务器存储一个资源,并用 Request-URI 作为其标识符 |
DELETE | 请求服务器删除由 Request-URI 所标识的资源 |
TRACE | 请求服务器回送到的请求信息,主要用于测试或诊断 |
CONNECT | 保留将来使用 |
OPTIONS | 请求查询服务器的性能,或者查询与资源相关的选项和需求 |
255.255.255.255和0.0.0.0的作用
255.255.255.255这个地址一般用来广播的时候使用,而0.0.0.0这个地址可以代表这是一个还没有分配 ip 的主机。
不过0.0.0.0还有其他作用,代表主机还没有分配ip地址只是其中的一个用处。例如:充当默认路由来使用,当一个路由器要发送路由表中无法查询的包时,如果设置了全零网络的路由时,我们就可以把这个包丢给全零网络的路由。
TCP和UDP区别以及各自适用场景
UDP提供不可靠无连接的数据报传输服务,使用IP实现报文传输,根据协议端口号确定收发双方的应用程序,适用于一个服务器需要对多个客户端频繁的小数据请求进行服务的情况。TCP提供可靠的面向连接的数据流传输服务,TCP偏重于可靠性,而不是实时性,适用于一对一的传输大量数据的场合。
TCP流量控制
流量控制与接收方的缓存状态相关。
一般来说,我们都希望数据能传输快一些。但是,如果发送方把数据发送得过快,接收方就可能来不及接收,这就会造成数据的丢失。
所谓流量控制(flow control)就是让发送方的发送速率不要太快,要让接收方来得及接收。
解决流量控制的问题的方法很多,比如停止-等待、滑动窗口等。
流量控制不是属于某一层特有的功能,比如数据链路层会要考虑,这里的TCP的也会要考虑(数据链路层的暂时不讨论)
TCP利用滑动窗口机制,可以比较好的成功做到流量控制。
详细过程可以参考这个教学视频
TCP拥塞机制
拥塞控制与网络拥堵情况相关。
- 在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络性能就要变坏。这种情况就叫做拥塞(congestion)。
- 在计算机网络中的链路容量(即带宽)、交换节点中的缓存和处理机等,都是网络资源。
- 若出现拥塞而不进行控制,整个网络的吞吐量将随输入负荷的增加而下降。
就好比平时我们路上如果车流量太大,没有交警的疏导的话就容易造成堵塞。
拥塞控制是很难设计的,因为网络是高度动态的,有时候甚至是因为这个拥塞控制,导致了网络的拥堵。
TCP的拥塞控制机制有四种算法:
- 慢开始(slow-start)
- 拥塞避免(congestion avoidance)
- 快重传(fast retransmit)
- 快回复(fast recovery)
下面介绍这四种拥塞控制算法,但是需要基于以下条件:
- 数据总是单向传送,而另一个方向只传送确认。
- 接收方总是有足够大的缓存空间(即不考虑流量控制的必要),因而发送方发送窗口的大小由网络的拥塞程度来决定。
- 以TCP报文段的个数作为讨论问题的单位,而不是以字节为单位。
TCP拥塞控制的示意图如下:
简而言之,一开始拥塞窗口cwnd的大小会是2的指数级来增加,直到其大小达到了慢开始门限值(ssthresh),后面就改用拥塞避免算法,即每次让cwnd加1。整个传输过程曲线的一个详细例子如下图:
在重新执行一次拥塞控制算法之后,整个的传输曲线是这样的:
- “慢开始”是指一开始向网络注入的报文段少,不是指拥塞窗口cwnd增长速度慢;
- “拥塞避免”不是指能完全避免拥塞,而是指在拥塞避免阶段将拥塞滑动窗口控制为按线性规律增长,使网络比较不容易出现拥塞。
上述的慢开始算法和拥塞避免算法是一起的(TCP Tahoe版本),在1988年被提出的。在1990年又增加了两个新的拥塞控制算法(改进TCP的性能),这就是快重传和快恢复(TCP Reno版本)。
- 为什么要有改进的算法?原先的慢开始拥塞避免算法不好么?
答:有时候,个别报文段会在网络中丢失,但是实际上网络并未发生拥塞。这会导致发送发认为已经发生拥塞,启动慢开始算法,把拥塞窗口(cwnd)设置为1,但是这会降低传输效率。
所谓快重传,就是使发送方尽快进行重传,而不是等超时重传计时器超时了再重传。
具体做法:
- 要求接收方不要等待自己发送数据时才进行捎带确认,而是立即发送确认。
- 即使收到了失序报文段也要立即发出对已收到的报文段的重复确认。
- 发送方一旦收到3个连续的重复确认,就将相应的报文段立即重传,而不是等该报文段的重传计时器超时再重传。
举一个图的例子,这里包含了TCP处理拥塞控制的四种方法:
详细内容可以参考这个教学视频
选择重传协议(SR)——可靠性传输协议中的一个
重传协议只重发没有正确接收的帧,而不是重发所有的帧。发送方为每个发送的帧设置一个定时器,收到应答就停止计时,超时未收到应答,说明帧丢失或出错,重发该帧。接收方收到序号正确的帧,就向发送方发送ACK应答信号,如果发现序号不连接,有丢失帧现象,就向发送方发送NAK信号,请求重发制定序号的帧。
提一下其他发送方和接收方的其他两个可靠性传输的实现机制:停止-等待协议(SW)和后退N帧协议(GBN)
选择重传协议也是基于滑动窗口流量控制技术的。它的接受窗口尺寸和发送窗口尺寸都大于 1,以便能够一次性接受多个帧。如果采用 n 个比特对帧机型编号,为了避免接受端向前移动窗口后,新接收窗口与旧接收窗口产生重叠,发送窗口的最大尺寸应该不超过序列号范围的一半。
举个例子:
在这个例子中,有四个分组序号 0、1、2、3 且窗口长度为 3。假定发送了分组 0 至 2,并且接收方被正确接收且确认了。此时,接收方窗口落在 4、5、6 个分组上,其序号分别为 3、0、1.现在考虑两种情况。
在第一种情况下,如上图中的 a 图所示,对前 3 个分组的 ACK 丢失,因此发送方重传这些分组。因此,接收方下一步要接收序号为 0 的分组,即第一个发送分组的副本。
在第二种情况下,如上图中的 b 图所示,对前 3 个分组的 ACK 都被正确交付。因此发送方向前移动窗口并发送第 4、5、6 个分组,其序号分别为 3、0、1.序号为 3 的分组丢失,但序号为 0 的分组到达(一个包含新数据的分组)。
显然,接收方并不知道发送方那边出现了什么问题,对于接收方自己来说,上面两种情况是等价的。没有办法区分是第一个分组的重传还是第 5 个分组的初次传输。所以,窗口长度比序号空间小 1 时协议无法正常工作。但窗口应该有多小呢?
答案是:窗口长度必须小于或等于序号空间大小的一半。
详细内容可以参考这个教学视频
以及这篇文章
TCP三次握手
回答过程中最好不要只回答SYN、ACK这样,而是把服务端和客户端发送之后的状态也说出来。
刚开始两端都处于 closed 的状态,开始传输之后,服务端会被动大开,进入 listen 状态。然后:
第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号seq(x)。发送报文之后,客户端进入 SYN_Send 状态。
第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 seq(y),同时会把客户端的 x + 1 作为 ack 的值(大的ACK表示确认收到,小的ack才表示序列号,ack为当前报文段最后一个字节的编号+1),表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_REVD 的状态。
第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 y + 1 作为 ack 的值,表示已经收到了服务端的 SYN 报文,之后客户端进入 establised 状态。
4、服务器收到 ACK 报文之后,进入 establised 状态,此时,双方以建立起了链接。
TCP三次握手涉及的问题
1.三次握手的作用
三次握手的作用也是有好多的,多记住几个,保证不亏。例如:
确认双方的接受能力、发送能力是否正常。
指定自己的初始化序列号,为后面的可靠传送做准备。
如果是 https 协议的话,三次握手这个过程,还会进行数字证书的验证以及加密密钥的生成到。
2.seq(ISN)是固定的么
三次握手的一个重要功能是客户端和服务端交换ISN(Initial Sequence Number), 以便让对方知道接下来接收数据的时候如何按序列号组装数据。
如果ISN是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。
3.什么是半连接队列
服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。
这里在补充一点关于SYN-ACK 重传次数的问题: 服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传,如果重传次数超 过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s, 2s, 4s, 8s, ….
4.三次握手过程中可以携带数据吗
很多人可能会认为三次握手都不能携带数据,其实第三次握手的时候,是可以携带数据的。也就是说,第一次、第二次握手不可以携带数据,而第三次握手是可以携带数据的。
为什么这样呢?大家可以想一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据,因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。也就是说,第一次握手可以放数据的话,其中一个简单的原因就是会让服务器更加容易受到攻击了。
而对于第三次的话,此时客户端已经处于 established 状态,也就是说,对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据页没啥毛病。
此外,三次握手还会涉及https加密过程。
5.第一次握手时可能会出现SYN超时的问题——针对SYN Flood的防护措施
面试回答建连的问题时,可以提到 SYN 洪水攻击发生的原因,就是 Server 端收到 Client 端的 SYN 请求后,发送了 ACK 和 SYN,但是 Client 端不进行回复,导致 Server 端大量的链接处在 SYN_RCVD 状态,进而影响其他正常请求的建连。可以设置 tcp_synack_retries = 0 加快半链接的回收速度,或者调大 tcp_max_syn_backlog 来应对少量的 SYN 洪水攻击。因为是Server端接收到了Client端的SYN包之后的回复的时候出现的问题,所以可以认为是首次握手时候产生的隐患。
6.建立连接后,client出现问题了怎么办?
——保活机制:1.相对方发送保活探测报文,如果未接收到相应则继续发送。2.尝试次数达到了设定的保活探测次数但是仍未收到响应的话,则中断连接。
TCP设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75分钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。
TCP四次挥手
四次挥手也一样,千万不要对方一个 FIN 报文,我方一个 ACK 报文,再我方一个 FIN 报文,我方一个 ACK 报文。然后结束,最好是说的详细一点,例如想下面这样就差不多了,要把每个阶段的状态记好。
刚开始双方都处于 establised 状态,客户端先发起关闭请求,则:
第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号seq。之后客户端进入CLOSED_WAIT1状态。
第二次握手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 + 1 作为 ack 报文的序列号值,表明已经收到客户端的报文了,然后当然也会附上自己产生的序列号seq=y, 之后服务端进入 CLOSE_WAIT2状态。
第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号seq=z。之后服务端进入 LAST_ACK 的状态。
第四次挥手:客户端收到 FIN 之后,一样发送一个 ack 报文作为应答,且把服务端的序列号值 + 1 作为自己 ACK 报文的序列号值,之后客户端进入 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态。注意这里客户端不再生成新的序列号,而是使用之前的x+1和z+1
服务端收到 ACK 报文之后,就处于关闭连接了,进入 CLOSED 状态。
TCP四次挥手涉及的问题
1.为什么客户端在最后要有2MSL的等待时间才能进入CLOSED状态,即为什么要有TIME_WAIT时间?
原因有两个:
- 保证TCP协议的全双工连接能够可靠关闭;
- 保证这次连接的重复数据段从网络中消失,防止端口被重用时可能产生数据混淆。
虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假想网络是不可靠的,有可以最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN片段。所以Client不能立即关闭,它必须确认Server接收到了该ACK。Client会在发送出ACK之后进入到TIME_WAIT状态。Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。所谓的2MSL是两倍的MSL(Maximum Segment Lifetime)。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。
2.四次挥手过程中大量Socket处在TIME_WAIT和CLOSE_WAIT状态,该如何解决?
需要注意,在四次挥手的过程中,可以提到在实际应用中有可能遇到大量Socket处在TIME_WAIT或者CLOSE_WAIT状态的问题。一般开启 tcp_tw_reuse 和 tcp_tw_recycle 能够加快 TIME-WAIT 的 Sockets 回收;而大量 CLOSE_WAIT 可能是被动关闭的一方存在代码 bug,没有正确关闭链接导致的。
一些状态的含义
LISTEN - 侦听来自远方TCP端口的连接请求;
SYN-SENT -在发送连接请求后等待匹配的连接请求;
SYN-RECEIVED - 在收到和发送一个连接请求后等待对连接请求的确认;
ESTABLISHED- 代表一个打开的连接,数据可以传送给用户;
FIN-WAIT-1 - 等待远程TCP的连接中断请求,或先前的连接中断请求的确认;
FIN-WAIT-2 - 从远程TCP等待连接中断请求;
CLOSE-WAIT - 等待从本地用户发来的连接中断请求;
CLOSING -等待远程TCP对连接中断的确认;
LAST-ACK - 等待原来发向远程TCP的连接中断请求的确认;
TIME-WAIT -等待足够的时间以确保远程TCP接收到连接中断请求的确认;
CLOSED - 没有任何连接状态;
TCP三次握手和四次挥手的对比总结
无论是建连还是断连,都是需要在两个方向上进行,只不过建连时,Server 端的 SYN 和 ACK 合并为一次发送,而断链时,两个方向上数据发送停止的时间可能不同,所以不能合并发送 FIN 和 ACK。
TCP要三次握手的原因
TCP的握手为什么要三次呢?最后一次不要了,改为两次握手,可以么?
假如现在客户端想向服务端进行握手,它发送了第一个连接的请求报文,但是由于网络信号差或者服务器负载过多,这个请求没有立即到达服务端,而是在某个网络节点中长时间的滞留了,以至于滞留到客户端连接释放以后的某个时间点才到达服务端,那么这就是一个失效的报文,但是服务端接收到这个失效的请求报文后,就误认为客户端又发了一次连接请求,服务端就会想向客户端发出确认的报文,表示同意建立连接。
假如不采用三次握手,那么只要服务端发出确认,表示新的建立就连接了。但是现在客户端并没有发出建立连接的请求,其实这个请求是失效的请求,一切都是服务端在自相情愿,因此客户端是不会理睬服务端的确认信息,也不会向服务端发送确认的请求,但是服务器却认为新的连接已经建立起来了,并一直等待客户端发来数据,这样的情况下,服务端的很多资源就没白白浪费掉了。
采用三次握手的办法就是为了防止上述这种情况的发生,比如就在刚才的情况下,客户端不会向服务端发出确认的请求,服务端会因为收不到确认的报文,就知道客户端并没有要建立连接,那么服务端也就不会去建立连接,这就是三次握手的作用。
NOTE:用更专业的内容可以说,之前发送过程中滞留的包,是”已失效的连接请求报文段“
TCP要四次挥手的原因
TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工 模式,这就意味着,在客户端想要断开连接时,客户端向服务端发送FIN
报文,只是表示客户端已经没有数据要发送了,但是这个时候客户端还是可以接收来自服务端的数据。
当服务端接收到FIN
报文,并返回ACK
报文,表示服务端已经知道了客户端要断开连接,客户端已经没有数据要发送了,但是这个时候服务端可能依然有数据要传输给客户端。
当服务端的数据传输完之后,服务端会发送FIN
报文给客户端,表示服务端也没有数据要传输了,服务端同意关闭连接,之后,客户端收到FIN
报文,立即发送给客户端一个ACK
报文,确定关闭连接。在之后,客户端和服务端彼此就愉快的断开了这次的TCP
连接。
或许会有疑问,为什么服务端的ACK
报文和FIN
报文都是分开发送的,但是在三次握手的时候却是ACK
报文和SYN
报文是一起发送的,因为在三次握手的过程中,当服务端收到客户端的SYN连接请求报文后,可以直接发送SYN
+ACK
报文。其中ACK
报文是用来应答的,SYN
报文是用来同步的。但是在关闭连接时,当服务端接收到FIN
报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK
报文,告诉客户端,你发的FIN
报文我收到了,只有等到服务端所有的数据都发送完了,才能发送FIN
报文,因此ACK
报文和FIN
报文不能一起发送。所以断开连接的时候才需要四次挥手来完成。
1.2 cookie和session的区别
参考这篇文章
- 存储位置不同
cookie的数据信息存放在客户端浏览器上。
session的数据信息存放在服务器上。
- 存储容量不同
单个cookie保存的数据<=4KB,一个站点最多保存20个Cookie。
对于session来说并没有上限,但出于对服务器端的性能考虑,session内不要存放过多的东西,并且设置session删除机制。
- 存储方式不同
cookie中只能保管ASCII字符串,并需要通过编码方式存储为Unicode字符或者二进制数据。
session中能够存储任何类型的数据,包括且不限于string,integer,list,map等。
- 隐私策略不同
cookie对客户端是可见的,别有用心的人可以分析存放在本地的cookie并进行cookie欺骗,所以它是不安全的。
session存储在服务器上,对客户端是隐藏,不存在敏感信息泄漏的风险。
- 有效期上不同
开发可以通过设置cookie的属性,达到使cookie长期有效的效果。
session依赖于名为JSESSIONID的cookie,而cookie JSESSIONID的过期时间默认为-1,只需关闭窗口该session就会失效,因而session不能达到长期有效的效果。
- 服务器压力不同
cookie保管在客户端,不占用服务器资源。对于并发用户十分多的网站,cookie是很好的选择。
session是保管在服务器端的,每个用户都会产生一个session。假如并发访问的用户十分多,会产生十分多的session,耗费大量的内存。
- 浏览器支持不同
假如客户端浏览器不支持cookie:
cookie是需要客户端浏览器支持的,假如客户端禁用了cookie,或者不支持cookie,则会话跟踪会失效。关于WAP上的应用,常规的cookie就派不上用场了。
运用session需要使用URL地址重写的方式。一切用到session程序的URL都要进行URL地址重写,否则session会话跟踪还会失效。
假如客户端支持cookie:
cookie既能够设为本浏览器窗口以及子窗口内有效,也能够设为一切窗口内有效。
session只能在本窗口以及子窗口内有效。
- 跨域支持上不同
cookie支持跨域名访问。
session不支持跨域名访问。
另外:现在正在淘汰cookie了,谷歌日前就在官方博客上说,将在未来两年淘汰cookie,即chrome逐步淘汰第三方cookie,但是由于市场占比的原因,这件事还有待进一步发展。
HTTP、TCP、UDP、IP、ICMP、DNS、FTP等协议分别处于哪层?
以OSI七层模型来说:
- 应用层:HTTP、FTP、DNS、Telnet、SMTP、
- 传输层:TCP、UDP
- 网络层:IP、ICMP、RIP、OSPF、
- 数据链路层:ARP、PPP、RARP、MTU
- 物理层:ISO2110、IEEEE802、IEEEE802.2
Ping指令用的什么协议?在哪一层?
Ping命令本身处于应用层,相当于一个应用程序,它直接使用网络层的ICMP回应报文来监听返回情况。(所以如果直接问ping在哪一层,答案是应用层)
ARP协议的作用?
什么是ARP协议?
ARP (Address Resolution Protocol) 是个地址解析协议。最直白的说法是:在IP以太网中,当一个上层协议要发包时,有了该节点的IP地址,ARP就能提供该节点的MAC地址。
为什么要有ARP协议?
OSI 模式把网络工作分为七层,彼此不直接打交道,只通过接口(layre interface). IP地址在第三层, MAC地址在第二层。协议在发生数据包时,首先要封装第三层 (IP地址)和第二层 (MAC地址)的报头, 但协议只知道目的节点的IP地址,不知道其物理地址,又不能跨第二、三层,所以得用ARP的服务。
路由器和交换机的区别?
交换机是一根网线上网,但是大家上网是分别拨号,各自使用自己的宽带,大家上网没有影响。而路由器比交换机多了一个虚拟拨号功能,通过同一台路由器上网的电脑是共用一个宽带账号,大家上网要相互影响。
交换机工作在中继层,交换机根据MAC地址寻址。路由器工作在网络层,根据IP地址寻址,路由器可以处理TCP/IP协议,而交换机不可以。
路由器工作于网络层,用来隔离广播域(子网),连接的设备分属不同子网,工作范围是多个子网之间,负责网络与网络之间通信。
工作层次不同
交换机主要工作在数据链路层(第二层)
路由器工作在网络层(第三层)。
转发依据不同
交换机转发所依据的对象时:MAC地址。(物理地址)
路由转发所依据的对象是:IP地址。(网络地址)
主要功能不同
交换机主要用于组建局域网,而路由主要功能是将由交换机组好的局域网相互连接起来,或者接入Internet。
交换机能做的,路由都能做。
交换机不能分割广播域,路由可以。
路由还可以提供防火墙的功能。
路由配置比交换机复杂。
TCP粘包为什么会发生?怎么处理?
只有TCP会产生粘包问题,因为TCP是基于数据流的协议,而UDP是基于数据报的协议。也就是说,TCP认为消息不是一条一条的,而是”流”式的,是没有消息边界的。而UDP则是有消息边界的,接收方一次只接收一条独立的信息,所以不存在粘包问题。
TCP粘包指的是发送方发送的若干包数据到达接收方时粘成了一个很大的包,从接收方来看,是一个数据包的头紧接着另一个数据包的尾。
发生TCP粘包主要是两个原因:
- 发送方的原因:发送方默认开启Nagle算法(主要作用是减少网络中报文段的数量),而Nagle算法主要做两件事:1.只在上一个分组得到确认之后才发送下一个分组;2.收集多个小分组,在一个确认到来之后一起发送。Nagle这两个功能造成了发送方可能会产生粘包问题。
- 接收方原因:接收方接收到TCP包之后不会马上处理,而是会先放在缓存中,然后应用程序会从缓存里面读取到数据分组。但是这样一来如果TCP接收数据包到缓存的速度大于应用程序从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接粘到一起的包。
解决粘包问题的两个方法:
经过上面原因的分析我们可以知道,可以在发送方和应用层两个层面解决TCP粘包问题,但是接收方没办法解决,从接受方的角度来看只能交给应用层处理。
- 发送方可以选择关闭Nagle算法,使用TCP_NODELAY选项关闭
- 从应用层来解决可以有两种方法,主要目的都是从缓存中循环地一条一条地读数据,直到所有数据被处理完成。这里重点是如何判断每条数据的长度,重点解决方法有两个:
- 格式化数据:给每条数据设置固有的格式(开始符、结束符,比叡设置每条数据的结尾都统一是”/0”),
- 统一发送长度:每次发送数据时都统一长度,然后将数据按照统一的长度发送。比如规定数据的前4位是数据的长度,之后应用层在处理时可以根据长度来判断每个分组的开始和结束位置。
其他有关路由器和交换机的知识:可以参考文章
2.操作系统
进程与线程的区别
从宏观上来回答:
a.进程是系统资源分配的最小单位,线程是程序执行的最小单位
b.进程使用独立的数据空间,而线程共享进程的数据空间
更深入一点:
- 共享内存方面。因为进程间不能共享内存,所以我们会用一些进程间相互交互的方案,比较常见的就是通过TCP/IP的端口来实现。也有其他方案,但是TCP/IP是最通用的,其他方案可能和某个特定操作系统的相关性要更大一些。
- 进程与线程通信方面。进程通信比较难,线程间通信就很简单了,只要两个线程的指针指向同一块内存,它们之间就可以通信。
- In terms of 开销,进程的开销当然比较大,因为我们要给它分配很多内存,而线程我们只是给它分配一个栈,分配一个PC指针(program counter)就可以了。此外,进程之间切换的开销会大于线程之间切换的开销。
进程间通信方式(IPC, Inter Process Communication)
总共七种方法:
- 文件
- 管道/命名管道
- Signal
- 共享内存
- 消息队列
- 同步机制,如信号量(semaphore)
- Socket
文件
写一个文件:最简单的方法,一个进程写一个文件,另一个进程去访问这个文件,由此两个进程之间可以交换信息,得以通信。
管道/命名管道
管道:两个进程之间建立消息通信的通道。不命名的管道一般是单向的,一方可以向另一方从管道里发送数据;命名管道可以单向也可以双向。
管道的通知机制类似于缓存,就像一个进程把数据放在某个缓存区域,然后等着另外一个进程去拿,并且是管道是单向传输的。
这种通信方式有什么缺点呢?显然,这种通信方式效率低下,你看,a 进程给 b 进程传输数据,只能等待 b 进程取了数据之后 a 进程才能返回。
所以管道不适合频繁通信的进程。当然,他也有它的优点,例如比较简单,能够保证我们的数据已经真的被其他进程拿走了。我们平时用 Linux 的时候,也算是经常用。
Signal
Signal:linux系统中最常用。一个进程给另一个进程发送信号,一般是一串数字,这个数字有自己特定的含义。比如”kill”可以 send a signal to a process,强行”kill”掉。
比如kill -9,可以杀死进程
共享内存
系统加载一个进程的时候,分配给进程的内存并不是实际物理内存,而是虚拟内存空间。那么我们可以让两个进程各自拿出一块虚拟地址空间来,然后映射到相同的物理内存中,这样,两个进程虽然有着独立的虚拟内存空间,但有一部分却是映射到相同的物理内存,这就完成了内存共享机制了。
换句话说:虽然进程之间是彼此独立的,但是操作系统可以提供一个机制,两个进程可以约定好,打开一个文件,这个文件映射到内存中,也就是多个进程使用同一块内存。
消息队列
我们可以用消息队列的通信模式来解决这个问题,例如 a 进程要给 b 进程发送消息,只需要把消息放在对应的消息队列里就行了,b 进程需要的时候再去对应的
消息队列里取出来。同理,b 进程要个 a 进程发送消息也是一样。这种通信方式也类似于缓存吧。
这种通信方式有缺点吗?答是有的,如果 a 进程发送的数据占的内存比较大,并且两个进程之间的通信特别频繁的话,消息队列模型就不大适合了。因为 a 发送的数据很大的话,意味发送消息(拷贝)这个过程需要花很多时间来读内存。
同步机制,如信号量(semaphore)
信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。
Socket
最后一种,而且没回答出来这个其实整个答案都不算好。因为前面6种只能作为同一个机器的进程之间的通信,而Socket可以是不同机器的进程之间的通信。
可以在机器上开一个端口,作为一个服务器,让用户连接。这种通信包含了网络上的服务端和服务器的这种结构。
用浏览器去访问一个网站,这时浏览器的进程和远端服务器的进程要进行通信。
Socket可以作为不同机器之间进程的通信——通过客户端,服务器的方法。这里面走的一般是TCP的协议或者UDP的协议。
进程切换与线程切换的区别?
要想正确回答这个问题,面试者需要理解虚拟内存。
虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有属于自己的、私有的、地址连续的虚拟内存,当然我们知道最终进程的数据及代码必然要放到物理内存上,那么必须有某种机制能记住虚拟地址空间中的某个数据被放到了哪个物理内存地址上,这就是所谓的地址空间映射。
操作系统通过页表记录虚拟内存地址到物理内存地址的映射关系。有了页表就可以将虚拟地址转换为物理内存地址了,这种机制就是虚拟内存。
每个进程都有自己的虚拟地址空间,进程内的所有线程共享进程的虚拟地址空间。
进程切换与线程切换的一个最主要区别就在于进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
因此我们可以形象的认为线程是处在同一个屋檐下的,这里的屋檐就是虚拟地址空间,因此线程间切换无需虚拟地址空间的切换;而进程则不同,两个不同进程位于不同的屋檐下,即进程位于不同的虚拟地址空间,因此进程切换涉及到虚拟地址空间的切换,这也是为什么进程切换要比线程切换慢的原因。
为什么虚拟地址切换很慢
进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个cache就是TLB,Translation Lookaside Buffer,我们不需要关心这个名字只需要知道TLB本质上就是一个cache,是用来加速页表查找的。由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。
【总结】进程切换会导致TLB失效,线程切换则不会。
死锁发生的四个条件和预防方式?
什么是死锁?——死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种进程间相互阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
产生死锁的四个条件
- 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
- 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
- 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
- 循环等待条件:若干进程间形成首尾相接循环等待资源的关系
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
避免死锁的方法
系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配,这是一种保证系统不进入死锁状态的动态策略。 如果操作系统能保证所有进程在有限时间内得到需要的全部资源,则系统处于安全状态否则系统是不安全的。
有序资源分配法
这种算法资源按某种规则系统中的所有资源统一编号(例如打印机为1、磁带机为2、磁盘为3、等等),申请时必须以上升的次序。系统要求申请进程:
1、对它所必须使用的而且属于同一类的所有资源,必须一次申请完;
2、在申请不同类资源时,必须按各类设备的编号依次申请。例如:进程PA,使用资源的顺序是R1,R2; 进程PB,使用资源的顺序是R2,R1;若采用动态分配有可能形成环路条件,造成死锁。
采用有序资源分配法:R1的编号为1,R2的编号为2;
PA:申请次序应是:1,2
PB:申请次序应是:1,2
这样就破坏了环路条件,避免了死锁的发生
银行家算法
避免死锁算法中最有代表性的算法是Dijkstra E.W 于1968年提出的银行家算法:
银行家算法是避免死锁的一种重要方法,防止死锁的机构只能确保上述四个条件之一不出现,则系统就不会发生死锁。通过这个算法可以用来解决生活中的实际问题,如银行贷款等。
程序实现思路银行家算法顾名思义是来源于银行的借贷业务,一定数量的本金要应多个客户的借贷周转,为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其是否能限期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源。如果资源分配不当就会发生进程循环等待资源,严重则导致进程都无法继续执行下去的死锁现象。
把一个进程需要和已占有资源的情况记录在进程控制中,假定进程控制块PCB其中“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的资源申请。“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的资源需求总量不能超过系统拥有的资源总数, 银行算法进行资源分配可以避免死锁。
死锁的检测和解除
检测死锁:这种方法并不须事先采取任何限制性措施,也不必检查系统是否已经进入不安全区,此方法允许系统在运行过程中发生死锁。但可通过系统所设置的检测机构,及时地检测出死锁的发生,并精确地确定与死锁有关的进程和资源。检测方法包括定时检测、效率低时检测、进程等待时检测等。
解除死锁:采取适当措施,在系统中将已发生的死锁解除。这是与检测死锁相配套的一种措施。当检测到系统中已发生死锁时,须将进程从死锁状态中解除。常用的实施方法是撤销或挂起一些进程,以便回收一些资源,再将这些资源分配给已处于阻塞状态的进程,使之转为就绪状态,以继续运行。死锁的检测和解除措施,有可能使系统获得较好的资源利用率和吞吐量,但在实现上难度也最大。
解除死锁的具体方法有:
资源剥夺法
挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。
撤销进程法
强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级和撤销进程代价的高低进行。
进程回退法
让一个或多个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。
经典进程调度算法
1.批处理时代
1.FCFS(先来先服务)
每个进程按进入内存的时间先后排成一队。每当 CPU 上的进程运行完毕或者阻塞,我就会选择队伍最前面的进程,带着他前往 CPU 执行。
这一算法听起来简单又公平,然而好景不长,我收到了一个短进程的抱怨:”上次我前面排了一个长进程,等了足足 200 秒他才运行完。我只用 1 秒就运行结束了,就因为等他,我多花了这么长时间,太不值得了。”
我仔细一想, FCFS 算法确实有这个缺陷——短进程的响应时间太长了,用户交互体验会变差。
所以我决定,更换调度算法。
2.SPN(段任务优先)
这次我设计的算法叫做「短任务优先」(Shortest Process Next,SPN)。每次选择预计处理时间最短的进程。因此,在排队的时候,我会把短进程从队列里提到前面。
这一次,短进程得到了很好的照顾,进程的平均响应时间大大降低,我和操作系统都很满意。
但长进程们不干了:那些短进程天天插队,导致他们经常得不到 CPU 资源,造成了「饥饿」现象。
取消 SPN 算法的呼声越来越高。
这可是个大问题。FCFS 虽然响应时间长,但最后所有进程一定有使用 CPU 资源的机会。但 SPN 算法就不一样了,如果短进程源源不断加入队列,长进程们将永远得不到执行的机会——太可怕了。
因此,短任务优先算法需要得到改进。有什么方法既能照顾短进程,又能照顾长进程呢?
3.HARRN(高响应比优先)
经过和操作系统的讨论,我们决定综合考量进程的两个属性:等待时间和要求服务时间——等待时间长,要求服务时间短(就是短进程)的进程更容易被选中。
为了量化,我们制定了一个公式:响应比 = (等待时间+要求服务时间)/ 要求服务时间。响应比高的算法会先执行。我们称之为「高响应比优先」(Highest Response Ratio Next,HRRN)。
这个算法得到了长短进程的一致好评。虽然我的工作量增加了(每次调度前,我都要重新计算所有等待进程的响应比)但为了进程们的公平性,这一切都是值得的。
2.并发时代
新时代到了。
随着计算机的普及,个人用户大量增长,并发,即一次运行多个程序的需求出现了。这可难倒我了——处理器只有一个,怎么运行多个程序?
所幸 CPU 点醒了我:“我现在的运算速度既然这么快,何不发挥这项长处,弄一个「伪并行」出来?“
“伪并行?什么意思”
“就是看起来像并行,实际上还是串行。每个进程短时间交替使用我的资源,但在人类看来,这些进程就像在「同时」运行。”
1.RR(时间片轮转算法)
经过 CPU 的提醒,我很快制定出了新的调度算法——时间片轮转算法(Round Robin,RR)。
在这个算法里,每个进程将轮流使用 CPU 资源,只不过在他们开始运行时,我会为他们打开定时器,如果定时器到时间(或者执行阻塞操作),进程将被迫「下机」,切换至下一个进程。至于下一个进程的选择嘛,直接用 FCFS 就好了。
新的算法必然会面临新的问题,现在我的问题就是,时间片的长度怎么设计?
直观来看,时间片越短,固定时间里可运行的进程就越多,可 CPU 说过,切换进程是要消耗他不少指令周期的,时间片过短会导致大量 CPU 资源浪费在切换上下文上。时间片过长,短交互指令响应会变慢。所以具体怎么取,还得看交互时间大小(感觉像没说一样,但至少给了个标准嘛)。
这一阶段,我的工作量大大提升——以前十几秒都不用切换一次程序,现在倒好,一秒钟就得切换数十次。
2.VRR(虚拟轮转法)
时间片轮转算法看起来十分公平——所有的进程时间片都是一样的。但事实真是这样吗?
I/O 密集型进程不这么认为,他对我说:“调度器大哥,时间片轮转没有照顾到我们这类进程啊!我们经常在 CPU 没呆到一半时间片,就遇到了阻塞操作,被你赶下去。而且我们在阻塞队列,往往要停留很长时间。等阻塞操作结束,我们还得在就绪队列排好长时间队。那些处理器密集型进程,使用了大部分的处理器时间,导致我们性能降低,响应时间跟不上”
考虑到这些进程的要求,我决定为他们创建一个新的辅助队列。阻塞解除的进程,将进入这个辅助队列,进行进程调度时,优先选择辅助队列里的进程。
这就是「虚拟轮转法」(Virtual Round Robin,VRR)。
从后来实际性能结果来看,这种方法确实优于轮转法。
3.优先级调度
这个非常类似于优先队列的思想。
有一天,操作系统忽然找到我,神神秘秘的说:“调度器啊,你是知道的,我要给整个系统提供服务,可最近用户进程太多,导致我的服务进程有时候响应跟不上。我有点担心这会给系统稳定性造成影响。”
我一听,这可是个大事,系统不稳定那还得了?调度算法得换!
既然要让操作系统的服务得到足够的运行资源,那就,干脆让他们具有最高的 CPU 使用优先权吧。
优先级调度算法就此产生了。
我向大家做出了规定——每个进程将被赋予一个优先级,自己根据自己的情况确定优先级数值,但是,用户进程的优先级不准高于内核进程的优先级。
切换程序的时候,我会从优先级 1 的队列里选择一个进程,如果优先级 1 队列为空,才会选择优先级 2 中的进程,以此类推。
当然,为了保证低优先级进程不会饥饿,我会调高等待时间长的进程的优先级。
使用这个算法,我更忙碌了,不仅需要大量切换进程,还需要动态调节优先级。可能这就是能力越大,责任越大吧。
不过我知道,正是因为我的存在,人类才能在计算机上运行多道程序——这令我感到自豪。
互斥
出现互斥的根本原因就是进程在执行某一个操作(比如购票操作,在操作过程中某个进程买票了,减了一张,如果另外一个进程也访问了买票系统,买了票,又减了1,那导致卖了两张票出去,出错了)
这本质是进程之间冲突造成的,一个进程修改了共享的空间的数据,另一个一个线程再访问的时候自然就会出错。
这个是进程调度器的锅么?——因为貌似进程调度器负责调度进程啊!
但是,并不是。因为进程调度器也只负责做事,它只负责从就绪队列中选出来最应该使用CPU的进程而已。具体说来,调度器的时机是由中断决定的,也就是当进程时间片用完的时候,出现了时钟中断,然后被其他进程抢占了CPU资源。
但是能因此就禁止中断么?当然不行。中断禁用虽然可以防止进程在运行代码,但是计算机自己不能控制执行的功能,全部交给程序员,这是不合理的。
解决方法:加锁。
加锁是个比喻,其实「锁」只是一个共享变量,我们可以让它有 OPEN
和 CLOSE
这两个值。一个进程,比如说 A,进入临界区之前,先检查锁是不是 OPEN
状态,如果是的话,就把锁改为 CLOSE
状态 ,这样其他进程在进入临界区时,会发现锁已经 CLOSE
了,那就让他们循环等待 ,直到 A 出临界区然后将锁打开。
内存眉头一皱,发现事情并没有这么简单——如果 A 发现锁是开着的,但在 A 还没有关闭锁之前,切换到了进程 B ,那么 B 也会发现锁是开着的,那么 B 也将能够进入临界区!
但是CPU说:”这对我来说,不是问题,已经有现成机制可以使用。”
原来计算机里有一条硬件支持的指令——TSL(test and set lock,测试并加锁),这条指令可以保证读字和写字的操作「不可分割」,也就是说,在这条指令结束前,就连其他处理器也不可能访问该内存字。
但是如果单纯设置这样的锁,一个进程没执行完,另一个不能执行,很可能会浪费CPU资源,所以单纯的TSL方案需要改进。
然而,磁盘想到了解决这个问题的方法。
利用信号量。。Dijkstra 提出,P
操作是检测信号量是否为正值,如果不是,就阻塞调用它的进程。 V
操作能唤醒一个被阻塞的进程,让他恢复执行 。
代码:
1 | // S 为信号量 |
举个例子,购票操作。这里的 「购票操作」 就是我们要保护的临界区,我们要保证一次只能有一个线程进入。那我们就把 S 的初始值设为 1 。当线程 A 第一个调用 P(S) 后,S 的值就变成了 0 ,A 成功进入临界区。在 A 出临界区之前,线程 B 如果调用 P(S), S 就变成 -1 ,满足 S < 0 的判断条件,线程 B 就被阻塞了。等 A 调用 V(S) 后,S 的值又变成 0 ,满足 S <= 0,就会把线程 B 唤醒,B 就能进入临界区了。“
而且信号量在这里有了可以增加线程运行速度的作用:S 的初始值可以控制有多少个线程进入临界区,太厉害了!
锁(重量级锁、自旋锁、轻量级锁、偏向锁、悲观、乐观锁等)
重量级锁
如果你学过多线程,那么你肯定知道锁这个东西,至于为什么需要锁,我就不给你普及了,就当做你是已经懂的了。
我们知道,我们要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。
这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁。
自旋锁
我们知道,线程从运行态进入阻塞态这个过程,是非常耗时的,因为不仅需要保存线程此时的执行状态,上下文等数据,还涉及到用户态到内核态的转换。当然,把线程从阻塞态唤醒也是一样,也是非常消耗时间的。
刚才我说线程拿不到锁,就会马上进入阻塞状态,然而现实是,它虽然这一刻拿不到锁,可能在下 0.0001 秒,就有其他线程把这个锁释放了。如果它慢0.0001秒来拿这个锁的话,可能就可以顺利拿到了,不需要经历阻塞/唤醒这个花时间的过程了。
然而重量级锁就是这么坑,它就是不肯等待一下,一拿不到就是要马上进入阻塞状态。为了解决这个问题,我们引入了另外一种愿意等待一段时间的锁 — 自旋锁。
自旋锁就是,如果此时拿不到锁,它不马上进入阻塞状态,而是等待一段时间,看看这段时间有没其他人把这锁给释放了。怎么等呢?这个就类似于线程在那里做空循环,如果循环一定的次数还拿不到锁,那么它才会进入阻塞的状态。
至于是循环等待几次,这个是可以人为指定一个数字的。
自适应自旋锁
上面我们说的自旋锁,每个线程循环等待的次数都是一样的,例如我设置为 100次的话,那么线程在空循环 100 次之后还没拿到锁,就会进入阻塞状态了。
而自适应自旋锁就牛逼了,它不需要我们人为指定循环几次,它自己本身会进行判断要循环几次,而且每个线程可能循环的次数也是不一样的。而之所以这样做,主要是我们觉得,如果一个线程在不久前拿到过这个锁,或者它之前经常拿到过这个锁,那么我们认为它再次拿到锁的几率非常大,所以循环的次数会多一些。
而如果有些线程从来就没有拿到过这个锁,或者说,平时很少拿到,那么我们认为,它再次拿到的概率是比较小的,所以我们就让它循环的次数少一些。因为你在那里做空循环是很消耗 CPU 的。
所以这种能够根据线程最近获得锁的状态来调整循环次数的自旋锁,我们称之为自适应自旋锁。
轻量级锁
上面我们介绍的三种锁:重量级、自旋锁和自适应自旋锁,他们都有一个特点,就是进入一个方法的时候,就会加上锁,退出一个方法的时候,也就释放对应的锁。
之所以要加锁,是因为他们害怕自己在这个方法执行的时候,被别人偷偷进来了,所以只能加锁,防止其他线程进来。这就相当于,每次离开自己的房间,都要锁上门,人回来了再把锁解开。
这实在是太麻烦了,如果根本就没有线程来和他们竞争锁,那他们不是白白上锁了?要知道,加锁这个过程是需要操作系统这个大佬来帮忙的,是很消耗时间的,。为了解决这种动不动就加锁带来的开销,轻量级锁出现了。
轻量级锁认为,当你在方法里面执行的时候,其实是很少刚好有人也来执行这个方法的,所以,当我们进入一个方法的时候根本就不用加锁,我们只需要做一个标记就可以了,也就是说,我们可以用一个变量来记录此时该方法是否有人在执行。也就是说,如果这个方法没人在执行,当我们进入这个方法的时候,采用CAS机制,把这个方法的状态标记为已经有人在执行,退出这个方法时,在把这个状态改为了没有人在执行了。
之所以要用CAS机制来改变状态,是因为我们对这个状态的改变,不是一个原子性操作,所以需要CAS机制来保证操作的原子性。
显然,比起加锁操作,这个采用CAS来改变状态的操作,花销就小多了。
然而可能会说,没人来竞争的这种想法,那是你说的而已,那如果万一有人来竞争说呢?也就是说,当一个线程来执行一个方法的时候,方法里面已经有人在执行了。
如果真的遇到了竞争,我们就会认为轻量级锁已经不适合了,我们就会把轻量级锁升级为重量级锁了。
所以轻量级锁适合用在那种,很少出现多个线程竞争一个锁的情况,也就是说,适合那种多个线程总是错开时间来获取锁的情况。
偏向锁
偏向锁就更加牛逼了,我们已经觉得轻量级锁已经够轻,然而偏向锁更加省事,偏向锁认为,你轻量级锁每次进入一个方法都需要用CAS来改变状态,退出也需要改变,多麻烦。
偏向锁认为,其实对于一个方法,是很少有两个线程来执行的,搞来搞去,其实也就一个线程在执行这个方法而已,相当于单线程的情况,居然是单线程,那就没必要加锁了。
不过毕竟实际情况的多线程,单线程只是自己认为的而已了,所以呢,偏向锁进入一个方法的时候是这样处理的:如果这个方法没有人进来过,那么一个线程首次进入这个方法的时候,会采用CAS机制,把这个方法标记为有人在执行了,和轻量级锁加锁有点类似,并且也会把该线程的 ID 也记录进去,相当于记录了哪个线程在执行。
然后,但这个线程退出这个方法的时候,它不会改变这个方法的状态,而是直接退出来,懒的去改,因为它认为除了自己这个线程之外,其他线程并不会来执行这个方法。
然后当这个线程想要再次进入这个方法的时候,会判断一下这个方法的状态,如果这个方法已经被标记为有人在执行了,并且线程的ID是自己,那么它就直接进入这个方法执行,啥也不用做
你看,多方便,第一次进入需要CAS机制来设置,以后进出就啥也不用干了,直接进入退出。
然而,现实总是残酷的,毕竟实际情况还是多线程,所以万一有其他线程来进入这个方法呢?如果真的出现这种情况,其他线程一看这个方法的ID不是自己,这个时候说明,至少有两个线程要来执行这个方法论,这意味着偏向锁已经不适用了,这个时候就会从偏向锁升级为轻量级锁。
所以呢,偏向锁适用于那种,始终只有一个线程在执行一个方法的情况。
乐观锁和悲观锁
最开始我们说的三种锁,重量级锁、自旋锁和自适应自旋锁,进入方法之前,就一定要先加一个锁,这种我们为称之为悲观锁。悲观锁总认为,如果不事先加锁的话,就会出事,这种想法确实悲观了点。
而乐观锁却相反,认为不加锁也没事,我们可以先不加锁,如果出现了冲突,我们在想办法解决,例如 CAS 机制,上面说的轻量级锁,就是乐观锁的。不会马上加锁,而是等待真的出现了冲突,在想办法解决。
互斥锁(重量级锁)也称为阻塞同步、悲观锁
小结
按照对方法加锁的量级,按照synchronized加锁的顺序,从轻到重,依次是:偏向锁->轻量级锁->重量级锁
分页、分段、段页式是什么?
段页式的存储管理主要是从进程的存储空间来看的。
页式存储管理
页式存储管理主要做三件事(面试的时候这三点可以言简意赅,重要!):
- 将进程逻辑空间分成若干个大小相同的页面(等分)
- 相应的把物理内存空间分成页面大小的物理块
- 以页面为单位把进程空间装进物理内存中分散的物理块
页面
首先,需要清楚页面的概念。
这个页面,就牵扯到了计算机组成原理的字和字块,其实这两者没有本质关联,但是字块和页面可以进行对比。
- 字块是相对物理设备的定义
- 页面则是相对逻辑空间的定义
实际上,在理解页式存储管理的时候,一般都会和内存碎片联系起来记忆学习。
如下图:
上图中,不同的节点用来存储页面,可以看到,图中示意的页面大小其实不太合适,因为这样的页面大小会导致节点二和节点三存不下,导致内存碎片地产生。
所以,我们在分配页面大小的时候需要注意两点:
- 页面大小应当适中,过大会难以分配,过小内存碎片会过多
- 页面大小通常是512B(字节)~8K
页表
页表记录进程逻辑空间与物理空间的映射。
如下图所示:
页表负责记录不同页面对应哪一个字块。
页式存储管理的问题和改进
问题:现代计算机系统可以支持非常大的逻辑地址空间(2^32~2^64),这样的话页表会变得非常大,需要占用非常多的存储空间.比如32位操作系统的寻址空间为4G,很占用空间。
为了解决这个问题,我们定义了多级页表。
多级页表如图:
多级页表中首先有一个根页表,根页表的每一个字块都指向其他的页表。这样使用的时候内存中只需要加载根页表,后面再使用的时候直接根据根页表去找其指向的实际存储内容的页表。
页式存储管理的最大问题:若有一段连续的逻辑分布在多个页面中,将大大降低执行效率,因为系统需要不断从内存中读取和访问不同的页面。
段式存储管理
段式存储主要做这么几件事:
- 将进程逻辑空间划分成若干大小不相等的块
- 根据连续的逻辑来对段的长度进行划分
- 使用主函数MAIN、子程序段X、子函数Y等进行空间分配
同样我们需要用一个表来保存逻辑空间到物理空间的映射。使用的是段表。
一个段表的例子如下图:
段表相比于页表,需要多一个段长的属性,因为段式存储管理是不等长的。
段式存储管理和页式存储管理的对比
相同点:
段式存储和页式存储都离散地管理了进程的逻辑空间
不同点:
- 页是物理单位,段是是逻辑单位
- 分也是为了合理利用空间,分段是满足用户要求
- 页大小由硬件固定,段长度可动态变化
- 页表信息是一维的,段表信息是二维的
段页式存储管理
段页式的存储管理,旨在集中段式管理和页式管理的优点。
页式存储管理的优点:可以有效提高内存利用率(虽然存在内存碎片);
段式存储管理优点:分段可以更好满足用户需求(逻辑是用户定义的)
所以我们在这里争取将两者优势结合,形成段页式存储管理。
段页式存储管理的核心原理:
- 先将逻辑空间按段式管理分成若干段
- 再把段内空间按页式管理分成若干页
段页式的逻辑图:
段页地址中,段号指进程逻辑空间具体哪一段、段内页号指段里面具体的某一页、页内地址为页里面具体的内容。
段式内存管理、页式内存管理和段页式内存管理的详细图示如下:
需要明确,不论是这三种管理方式的哪一种,都是针对进程的存储空间进行管理,即都是为了将进程的存储空间映射到物理的内存空间中。
Linux统计文件某个字符出现的次数 怎么实现?
要统计一个字符串出现的次数,这里现提供自己常用两种方法:
- 使用vim统计
用vim打开目标文件,在命令模式下,输入
1 | :%s/objStr//gn |
- 使用grep:
1 | grep -o objStr filename | wc -l |
- 如果是多个字符串出现次数,可使用:
1 | grep -o ‘objStr1\|objStr2' filename|wc -l #直接用\| 链接起来即可 |
3.计算机组成原理
为什么寄存器比内存快?
计算机的存储层次(memory hierarchy)之中,寄存器(register)最快,内存其次,最慢的是硬盘。
同样都是晶体管存储设备,为什么寄存器比内存快呢?
原因一:距离不同
距离不是主要因素,但是最好懂,所以放在最前面说。内存离CPU比较远,所以要耗费更长的时间读取。
以3GHz的CPU为例,电流每秒钟可以振荡30亿次,每次耗时大约为0.33纳秒。光在1纳秒的时间内,可以前进30厘米。也就是说,在CPU的一个时钟周期内,光可以前进10厘米。因此,如果内存距离CPU超过5厘米,就不可能在一个时钟周期内完成数据的读取,这还没有考虑硬件的限制和电流实际上达不到光速。相比之下,寄存器在CPU内部,当然读起来会快一点。
这里说明下:之所以是5厘米而不是10厘米,是因为算的是往返的路程。
距离对于桌面电脑影响很大,对于手机影响就要小得多。手机CPU的时钟频率比较慢(iPhone 5s为1.3GHz),而且手机的内存紧挨着CPU。
原因二:硬件设计不同
苹果公司新推出的iPhone 5s,CPU是A7,寄存器有6000多位(31个64位寄存器,加上32个128位寄存器)。而iPhone 5s的内存是1GB,约为80亿位(bit)。这意味着,高性能、高成本、高耗电的设计可以用在寄存器上,反正只有6000多位,而不能用在内存上。因为每个位的成本和能耗只要增加一点点,就会被放大80亿倍。
原因三:工作方式不同
寄存器的工作方式很简单,只有两步:
(1)找到相关的位
(2)读取这些位
内存的工作方式就要复杂得多:
(1)找到数据的指针。(指针可能存放在寄存器内,所以这一步就已经包括寄存器的全部工作了。)
(2)将指针送往内存管理单元(MMU),由MMU将虚拟的内存地址翻译成实际的物理地址。
(3)将物理地址送往内存控制器(memory controller),由内存控制器找出该地址在哪一根内存插槽(bank)上。
(4)确定数据在哪一个内存块(chunk)上,从该块读取数据。
(5)数据先送回内存控制器,再送回CPU,然后开始使用。
内存的工作流程比寄存器多出许多步。每一步都会产生延迟,累积起来就使得内存比寄存器慢得多。
为了缓解寄存器与内存之间的巨大速度差异,硬件设计师做出了许多努力,包括在CPU内部设置缓存、优化CPU工作方式,尽量一次性从内存读取指令所要用到的全部数据等等。
4.Linux
线上服务器CPU占用率高如何排查定位问题?
如果 cpu 很高,但项目的性能却更差了,你会怎么排查?而且还要具体定位到出问题的代码在那里
一个参考的回答,一共分成九步:
top -c 查看所有的进程
在1的基础上键入P让cpu从高到底排序
选择2中cpu占比最高的pid进程
top -Hp pid 查看pid对应的线程对cpu的占比
在4的页面键入P让当前pid的线程cpu占比从高到低排序
获取第5步骤中的线程占比最高的线程id,由于linux打印的id是16进制的
将第6的线程id十六进制转为10进制 print “%xn” tid
打印指定pid下指定tid的jstack日志,jstack pid | grep tid -C 10 –color
根据堆栈信息找到代码块
epoll和select
现在几乎所有服务器用的都是epoll实现的,属于IO多路复用的内容。
epoll是一种方案,放弃了多进程、多线程、多协程,而是用单进程和单线程来实现高并发。
实际开发中,不会要你重写一个epoll服务器和多进程服务器,但是要了解epoll。
传统的单线程非阻塞IO的性能瓶颈,在于FD(套接字返回的对象)每次都保存在应用程序空间(用户态)中,这样,每次使用的时候都需要从用户态往内核态来回一换一次(而且伴有FD数据的复制过程),而且随着FD数量增加,服务器返回的FD的列表长度也会越长,导致性能低下。
但是epoll开创了新的存储方式:
共享内存,它定义了一块内存空间,这块空间既不属于用户空间,也不属于系统kernel空间,而是应用程序和kenel共享的。
在这个内存中所有添加的,判断的数据套接字,对应的数字描述符,我在检测的时候,不用轮询,而是事件通知。也就是把时间花在直接操作修改FD上,而不是查找。
epoll原理图如下:
epoll优势:
- 减少了FD对象的用户态与内核态之间的复制过程。
- 采用事件通知的方式。FD对象很多,内部list很长的时候,也是个问题。
总的来说,epoll只是轮询那些真正发生的流、但select会轮询所有的流,并且只依次顺序处理已经就绪的流,这种做法避免了大量的无用操作。
5.数据库
什么是sql注入?如何防止sql注入?
什么是sql注入以及是如何产生的
参考文章:https://www.cnblogs.com/gyrgyr/p/9876569.html
sql注入是一种将sql代码添加到输入参数中,传递到sql服务器解析并执行的一种攻击手段。
举例:
1 | SELECT * FROM article WHERE id=1 |
这条语句能查出来id为1的对应的内容。
但是当我们往id传的参数中注入sql代码时,就可以根据自己需求查询自己想要获取的内容。
举例:
1 | SELECT * FROM article WHERE id=1 OR 1=1 |
id值传参数为 -1 OR 1=1 ,此时执行代码中id值带入了我们传参数的sql代码, 1=1 为真,OR 1=1便会查出表中所有的内容。达到攻击目的。所以sql注入攻击就是输入参数未经过滤,直接拼接到sql语句中,解析执行,达到预想之外的行为。
SQL注入是如何产生的?
- web开发人员无法保证所有的输入都已经过滤
- 攻击者利用发送给sql服务器的输入数据构造可执行代码
- 数据库未做相应安全配置(对web应用设置特定的数据库账号,而不使用root或管理员账号,特定数据库账号给予一些简单操作的权限,回收一些类似drop的操作权限)
进行sql注入攻击的手段
这里介绍两种方式:
- 数字注入
sql中where条件的参数值为数字的语句进行修改攻击。
也就是上面提到的 id = -1 OR 1=1
- 字符串注入
- 以sql中的注释符号‘#’来实现攻击——我们只需要知道数据库中的某一个用户的用户名,比如peter,在表单输入时,在用户名列输入 “peter’#”,密码随意输入,点击登陆后便会显示登陆成功
- 以注释符号‘ – ’来实现攻击: 还是一样只需要知道数据库中的某一个用户的用户名,比如peter,在表单输入时,在用户名列输入 “peter’– ” (双中横线后还有空格),密码随意输入,点击登陆后便会显示登陆成功。
预防SQL注入(三种方法)
- 严格检查输入变量的类型和格式
- 1.对数字类型的参数id的强校验(empty()为空验证和is_numeric()进行数字验证)
- 2.对字符串类型的参数的校验 (正则校验)。例如上面提到的登陆系统的用户名的校验,比如校验规则为 六位数字以上的字母或者数字,可以用preg_match(“/^[a-zA-Z0-9]{6,}$/“)
- 过滤和转义特殊字符
- 1.用php函数addslashes()进行转义(addslashes函数使用方法详解点这里):一般是对这些特殊字符进行转义:1.单引号(’) 2.双引号(”) 3.反斜杠(\) 4. NULL
- 2.用mysqli的php扩展中的函数 mysqli_real_escape_string()
注:这两种方法只做简单介绍用,但其实现在的黑客已经可以轻而易举的绕过这些函数,包括一些字符串替换 str_replace() 等,表着急,继续往下看,下面介绍的第三种防sql注入的方法还是比较实在,如果需要还是直接用下面的方法吧~
- 利用预编译机制(mysqli 和 pdo)
- 1.DML语句预编译(mysqli示例和pdo示例)(使用mysqli需要开启扩展详细教程点我) (使用pdo需要开启扩展详细教程点我)
- 2.DQL语句预编译(mysqli示例)
数据库各种join语句的区别(left、inner、right)
在mysql数据库中经常会用到”join”,其中比较常用的是left join
、right join
、inner join
。前三者实际上属于outer join
,也就是其本质是left outer join
、right outer join
,但是其关键字outer
可以省略不写。
内连接用于返回满足连接条件的记录;而外连接则是内连接的扩展,它不仅会满足连接条件的记录,而且还会返回不满足连接条件的记录。
首先,当我们在进行多表联合查询的时候,会默认进行笛卡尔积运算。比如我们现在有两张表,一张Student表有5个项,一张Class表有3个项,则用select * from Student,Class
查找的时候,会默认返回15个项(详见链接例子)
但是这是我们一般不用的方法。
内连接(inner join):如用inner join,则返回满足条件的所有记录,默认情况下为内连接(inner join)
左外连接(left join):左外连接查询,不仅返回满足条件的所有记录,而且还会返回不满足连接条件的连接操作符左边表的其他行。即用left join查询的结果,左表为主表,右表连接可以为NULL。比如我们在Student表中插入了一个新的学生,他没有ID,但是在查询过程中如果用了left join,就能够查出来他,但是ID对应的会是NULL。比如:
slelect stu.StudentName,cl.ClassName from Student stu **left join** Class cl on stu.StudentClassID=cl.ClassID
;右外连接(right join):右外连接查询不仅返回满足条件的所有记录,而且还会返回不满足连接条件的连接操作符右边的其他行。即用right join查询的结果,右表为主表,左表连接可以为NULL。还是和上面类似,如果我们在班级Class表中新增班级,但是没有针对这个班级新增学生,则当查询的时候,ClassName会有显示,但是StudentName会是NULL。查询语句样例:
select stu.StudentName,cl.ClassName from Student stu right join Class cl on stu.StudentClassID=cl.ClassID;
一条SQL语句执行得很慢的原因有哪些?
首先说一下总的答案:
一个 SQL 执行的很慢,我们要分两种情况讨论:
- 大多数情况下很正常,偶尔很慢,则有如下原因
(1)、数据库在刷新脏页,例如 redo log 写满了需要同步到磁盘。
(2)、执行的时候,遇到锁,如表锁、行锁。
- 这条 SQL 语句一直执行的很慢,则有如下原因。
(1)、没有用上索引:例如该字段没有索引;由于对字段进行运算、函数操作导致无法用索引。
(2)、数据库选错了索引(数据库自己预测不使用你的索引能够更快)。主要由于统计失误,导致系统没有走索引,而是走了全表扫描,从而导致某条SQL执行很慢。
数据库在执行语句之前会做采样,如果采样之后发现走索引不比不走索引快,那么可能不走索引了,那么你定义的索引可能有问题,比如对表的覆盖程度不够。
数据库事务的四大特性是什么?
ACID
- Atomicity:原子性,一个事务要么全部完成,要么全部失败。通俗的另一种说法是:要么都做,要么都不做。比如银行取钱,我这边还没取完,你就不能操作我的账户。比较相似的是,如果一个用户正在操作他的账户,而且他的账户里面的钱的金额很大,可能出现的情况就是他点确认了,但是数据要处理几秒,那么这段时间这个账户也是不能被操作的。
原子性的核心是:利用一个undo日志来记录事务可以回滚的各个数据版本。即当目前执行的事务发生故障时,需要回滚,可以根据undo日志中的记录来知道事务回滚到哪一步停止。这样来保证事务由当下状态回滚到开始执行前的状态。
不要把原子性和一致性、隔离性混淆。原子性并不能保证看不到数据的中间状态,而一致性和隔离性才可以保障用户看不到数据的中间状态。
- Consistency,一致性,就是从一个状态转变到另一个状态,没有数据的中间状态。比如用户进行取钱操作,我们建立三个”视点”,在事务变化的过程中可以通过切换视点来读取事务在不同时刻的状态值。比如要解决一致性问题,我们可以用两种思路:将视点上移读取到旧数据、或者将视点下移读取到更新后的数据。总之,保持一致性就是保证不读取中间状态的数据。
比如,视点1是读取事务开始前的旧数据,视点2代表读取事务结束后的新数据,视点3是读取事务执行中的中间状态的数据。如下图:
在处理一致性问题时,有两种方法:上移视点3到视点1,读取事务开始前的旧数据;下移视点3到视点2,读取更新后的新数据。可见保证一致性就是保证不读取中间状态的数据。
- Isolation,隔离性,隔离性实际上是以提高性能为目的,对一致性地破坏。事务之间是互相独立的。一个事务的执行不能影响其他事务。
- Durability,持久性,事务需要是持久的,比如介质受损了,比如断电之后,数据也还能保存。
其中,Isolation,隔离性,是最关键的一个属性。
事务隔离级别有四个。事务会先begin transaction,然后开始做。
- Read uncommitted,读未提交,对所有事务只加写锁,不加读锁。即只有写写不可以并行。但是这回导致读事务读到一些中间状态的数据,即脏读。事务的隔离级别非常低,别的事务完成到了一半还没committ的时候,就能够被我读出来。(不能避免脏读)
- Read Committed,读已提交,允许读后写并行。读到的任何数据都是提交的数据,避免读到中间的未提交的数据。但是无法避免不可重复读。因为读事务第一次读取数据之后,另一个写事务可能会修改此事务,导致读事务第二次读取数据和第一次所读到的不一样。但是这种场景并不是经常出现,系统的一致性可以接受,因此多数数据库的默认级别是读已提交。(可以避免脏读)
- Repeatable Reads,可重复度,利用共享锁和排写锁实现。读读并行,禁止任何写事务并行。这个针对上一个,这种读法始终只能读取到我自己begin transaction时候的值。
- Serializable,可序列化,事务隔离级别最高,并发性最差,利用排它锁实现。 针对同一资源,将所有的请求事务进行排序,一个一个顺序执行。最高限度的保证了数据的一致性。两个事务同时发生的时候,一定只会读取到其中一个的结果。
什么是MVCC?——不可重复读隔离方式的实现之一
MVCC全称是:Multi-Version Concurrency Control,是一种不利用锁机制实现的隔离级别,主要实现了在保证数据的一致性的前提下实现读写并行。
MVCC原理是给每一个数据的更新都添加一个版本号。当写事务正在进行时,此时过来一个读事务,读事务会首先生成一个版本号,即该事务想读取哪一个版本的数据。然后,写事务更新数据,读事务读取之前相应版本的数据,而保证了不出现不可重复读和脏读的情况。
之前提到,保证数据一致性有两种方式,要么将读事务读取视点1的数据,要么读取视点2的数据。在mvcc中保证数据一致性的方法选择的是,读取视点1的数据,即读取的是写事务开始之前的旧版本数据。
实际上,MVCC是不可重复读隔离机制的实现方法之一。mysql为了实现线程不阻塞,采用了mvvc机制,使用版本号进行事务的隔离,其采用的方式有点类似java的cas机制,最大好处就是不加锁,但是只有在Innodb的引擎下存在,其实就是实现行锁的一种方式,可以理解为乐观锁实现行锁,对于其他引擎主要是悲观锁实现行锁。
并发事务执行的时候会有什么问题
在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对统一数据进行操 作)。并发虽然是必须的,但可能会导致以下的问题。
更新丢失(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事 务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1, 终结果A=19,事务1的修改被丢失。 (这个问题mysql所有事务隔离级别在数据库层面上都能避免,因为所有事务最低会在写的时候加锁,读的时候不加锁,但是写的时候加了写锁,就已经能够保证更新丢失这个问题了。)
脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这 时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个 事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务 也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的 数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发 事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就 好像发生了幻觉一样,所以称为幻读
解决方法:加上事务隔离级别
- 更新丢失——Mysql所有事务隔离级别在数据库层面上均可避免。
- 脏读——READ-COMMITTED事务隔离级别以上可以避免
- 不可重复读——REPEATABLE-READ事务隔离级别以上可以避免
- 幻读——SERIALIZABLE事务隔离级别可避免
不可重复度和幻读区别:
不可重复读的重点是修改,幻读的重点在于新增或者删除。
例1(同样的条件, 你读取过的数据, 再次读取出来发现值不一样了 ):事务1中的A先生读取自己的工资为 1000的操 作还没完成,事务2中的B先生就修改了A的工资为2000,导 致A再读自己的工资时工资变为 2000;这就是不可重复 读。
例2(同样的条件, 第1次和第2次读出来的记录数不一样 ):假某工资单表中工资大于3000的有4人,事务1读取了所 有工资大于3000的人,共查到4条记录,这时事务2 又插入了一条工资大于3000的记录,事务1再次读取时查到的记 录就变为了5条,这样就导致了幻读。
通过在写的时候加锁,可以解决脏读。
通过在读的时候加锁,可以解决不可重复读。
通过串行化,可以解决幻读。
以上这几种解决方案其实是数据库的几种隔离级别。
讲一下数据库的隔离级别?
SQL 标准定义了四个隔离级别:
- READ-UNCOMMITTED(读取未提交): 低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。解决了回滚覆盖类型的更新丢失,但可能发生脏读现象,也就是可能读取到其他会话中未提交事务修改的数据。
- READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读 仍有可能发生。只能读取到其他会话中已经提交的数据,解决了脏读。但可能发生不可重复读现象,也就是可能在一个事务中两次查询结果不一致。
- REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修 改,可以阻止脏读和不可重复读,但幻读仍有可能发生。 解决了不可重复读,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上会出现幻读,简单的说幻读指的的当用户读取某一范围的数据行时,另一个事务又在该范围插入了新行,当用户在读取该范围的数据时会发现有新的幻影行。
- SERIALIZABLE(可串行化): 高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务 之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。所有的增删改查串行执行。它通过强制事务排序,解决相互冲突,从而解决幻度的问题。这个级别可能导致大量的超时现象的和锁竞争,效率低下。
总结上面这几种情况如下图所示:
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过 SELECT @@tx_isolation;命令来查看。
这里需要注意的是:与 SQL 标准不同的地方在于InnoDB 存储引擎在 REPEATABLE-READ(可重读)事务隔离级别 下使用的是Next-Key Lock 锁算法,因此可以避免幻读的产生,这与其他数据库系统(如 SQL Server)是不同的。所以 说InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读) 已经可以完全保证事务的隔离性要 求,即达到了 SQL标准的SERIALIZABLE(可串行化)隔离级别。
因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是READ-COMMITTED(读取提交内容):,但是你要知道的是InnoDB 存储引擎默认使用 REPEATABLE-READ(可重读)并不会有任何性能损失。
InnoDB 存储引擎在 分布式事务 的情况下一般会用到SERIALIZABLE(可串行化)隔离级别。
联合索引是什么?它和多个单例索引的区别?
联合索引是什么
联合索引:对多个字段同时建立的索引(有顺序,ABC,ACB是完全不同的两种联合索引。)
为什么用联合索引
以联合索引(a,b,c)为例
- 建立这样的索引相当于建立了索引a、ab、abc三个索引。一个索引顶三个索引当然是好事,毕竟每多一个索引,都会增加写操作的开销和磁盘空间的开销。
- 覆盖(动词)索引。同样的有联合索引(a,b,c),如果有如下的sql: select a,b,c from table where a=xxx and b = xxx。那么MySQL可以直接通过遍历索引取得数据,而无需读表,这减少了很多的随机io操作。减少io操作,特别的随机io其实是dba主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。
- 索引列越多,通过索引筛选出的数据越少。有1000W条数据的表,有如下sql:select * from table where a = 1 and b =2 and c = 3,假设每个条件可以筛选出10%的数据,如果只有单值索引,那么通过该索引能筛选出1000W*10%=100w 条数据,然后再回表从100w条数据中找到符合b=2 and c= 3的数据,然后再排序,再分页;如果是复合索引,通过索引筛选出1000w *10% *10% *10%=1w,然后再排序、分页,哪个更高效,一眼便知。
使用时注意什么
- 单个索引需要注意的事项,组合索引全部通用。比如索引列不要参与计算啊、or的两侧要么都索引列,要么都不是索引列啊、模糊匹配的时候%不要在头部啦等等
- 最左匹配原则。(A,B,C) 这样3列,mysql会首先匹配A,然后再B,C.
如果用(B,C)这样的数据来检索的话,就会找不到A使得索引失效。如果使用(A,C)这样的数据来检索的话,就会先找到所有A的值然后匹配C,此时联合索引是失效的。 - 把最常用的,筛选数据最多的字段放在左侧。
有关多个单例索引和联合索引的区别详解
为了提高数据库效率,建索引是家常便饭;那么当查询条件为2个及以上时,我们是创建多个单列索引还是创建一个联合索引好呢?他们之间的区别是什么?哪个效率高呢?我在这里详细测试分析下。
我们选择 explain
查看执行计划来观察索引利用情况:
可以看到,如果走了索引,会在possible_keys上显示出来。
结论
通俗理解:
利用索引中的附加列,缩小搜索的范围,但使用一个具有两列的索引 不同于使用两个单独的索引。复合索引的结构与电话簿类似,人名由姓和名构成,电话簿首先按姓氏对进行排序,然后按名字对有相同姓氏的人进行排序。如果您知道姓,电话簿将非常有用;如果您知道姓和名,电话簿则更为有用,但如果您只知道名不知道姓,电话簿将没有用处。
所以说创建复合索引时,应该仔细考虑列的顺序。对索引中的所有列执行搜索或仅对前几列执行搜索时,复合索引非常有用;仅对后面的任意列执行搜索时,复合索引则没有用处。
重点:
多个单列索引在多条件查询时优化器会选择最优索引策略,可能只用一个索引,也可能将多个索引全用上! 但多个单列索引底层会建立多个B+索引树,比较占用空间,也会浪费一定搜索效率,故如果只有多条件联合查询时最好建联合索引!
最左前缀原则:
顾名思义是最左优先,以最左边的为起点任何连续的索引都能匹配上,
注:如果第一个字段是范围查询需要单独建一个索引
注:在创建联合索引时,要根据业务需求,where子句中使用最频繁的一列放在最左边。这样的话扩展性较好,比如 userid 经常需要作为查询条件,而 mobile 不常常用,则需要把 userid 放在联合索引的第一位置,即最左边
联合索引本质
当创建(a,b,c)联合索引时,相当于创建了(a)单列索引,(a,b)联合索引以及(a,b,c)联合索引
想要索引生效的话,只能使用 a和a,b和a,b,c三种组合;当然,我们上面测试过,a,c组合也可以,但实际上只用到了a的索引,c并没有用到!
注:这个可以结合上边的 通俗理解 来思考!
什么是联合索引的最左匹配原则?
以下回答全部是基于MySQL的InnoDB引擎
给出一张表作为例子:
如果我们按照 name 字段来建立索引的话,采用B+树的结构,大概的索引结构如下
如果我们要进行模糊查找,查找name 以“张”开头的所有人的ID,即 sql 语句为
select ID from table where name like '张%'
由于在B+树结构的索引中,索引项是按照索引定义里面出现的字段顺序排序的,索引在查找的时候,可以快速定位到 ID 为 100的张一,然后直接向右遍历所有张开头的人,直到条件不满足为止。
也就是说,我们找到第一个满足条件的人之后,直接向右遍历就可以了,由于索引是有序的,所有满足条件的人都会聚集在一起。
但是,假设我们已经创建了联合索引(a,b,c),那么系统默认会生成索引:(a)、(a,b)、(a,b,c)。
当b+树的数据项是复合的数据结构,比如(name,age,sex)的时候,b+数是按照从左到右的顺序来建立搜索树的,比如当(张三,20,F)这样的数据来检索的时候,b+树会优先比较name来确定下一步的所搜方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据来的时候,b+树就不知道下一步该查哪个节点,因为建立搜索树的时候name就是第一个比较因子,必须要先根据name来搜索才能知道下一步去哪里查询。比如当(张三,F)这样的数据来检索时,b+树可以用name来指定搜索方向,但下一个字段age的缺失,所以只能把名字等于张三的数据都找到,然后再匹配性别是F的数据了, 这个是非常重要的性质,即索引的最左匹配特性。
而这种定位到最左边,然后向右遍历寻找,就是我们所说的联合索引最左匹配原则。
什么是聚簇索引和非聚簇索引?
参考这篇文章:浅谈聚簇索引和非聚簇索引的区别
首先区别:
- 聚簇索引:将数据存储与索引放到了一块,找到索引也就找到了数据。(索引结构的叶子节点保存了行数据)
- 非聚簇索引(辅助索引):将数据与索引分开存储,索引结构的叶子节点指向了数据对应的位置
聚簇索引具有唯一性
由于聚簇索引是将数据索引结构放到一块,所以一个表仅有一个聚簇索引。
一个误区:把主键自动设为聚簇索引
聚簇索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnoDB 只聚集在同一个页面中的记录。包含相邻健值的页面可能相距甚远。
如果你已经设置了主键为聚簇索引,必须先删除主键,然后添加我们想要的聚簇索引,最后恢复设置主键即可。
此时其他索引只能被定义为非聚簇索引。这个是最大的误区。有的主键还是无意义的自动增量字段,那样的话Clustered index对效率的帮助,完全被浪费了。
刚才说到了,聚簇索引性能最好而且具有唯一性,所以非常珍贵,必须慎重设置。一般要根据这个表最常用的SQL查询方式来进行选择,某个字段作为聚簇索引,或组合聚簇索引,这个要看实际情况。
记住我们的最终目的就是在相同结果集情况下,尽可能减少逻辑IO。
InnoDB和MyISAM数据库的聚簇索引
- InnoDB使用的是聚簇索引,将主键组织到一棵B+树中,而行数据就储存在叶子节点上,若使用”where id = 14”这样的条件查找主键,则按照B+树的检索算法即可查找到对应的叶节点,之后获得行数据。
- 若对Name列进行条件搜索,则需要两个步骤:第一步在辅助索引B+树中检索Name,到达其叶子节点获取对应的主键。第二步使用主键在主索引B+树种再执行一次B+树检索操作,最终到达叶子节点即可获取整行数据。(重点在于通过其他键需要建立辅助索引)
MyISM使用的是非聚簇索引,非聚簇索引的两棵B+树看上去没什么不同,节点的结构完全一致只是存储的内容不同而已,主键索引B+树的节点存储了主键,辅助键索引B+树存储了辅助键。表数据存储在独立的地方,这两颗B+树的叶子节点都使用一个地址指向真正的表数据,对于表数据来说,这两个键没有任何差别。由于索引树是独立的,通过辅助键检索无需访问主键的索引树。
聚簇索引的优势
看上去聚簇索引的效率明显要低于非聚簇索引,因为每次使用辅助索引检索都要经过两次B+树查找,这不是多此一举吗?聚簇索引的优势在哪?
- 由于行数据和叶子节点存储在一起,同一页中会有多条行数据,访问同一数据页不同行记录时,已经把页加载到了Buffer中,再次访问的时候,会在内存中完成访问,不必访问磁盘。这样主键和行数据是一起被载入内存的,找到叶子节点就可以立刻将行数据返回了,如果按照主键Id来组织数据,获得数据更快。
- 辅助索引使用主键作为”指针”而不是使用地址值作为指针的好处是,减少了当出现行移动或者数据页分裂时辅助索引的维护工作,使用主键值当作指针会让辅助索引占用更多的空间,换来的好处是InnoDB在移动行时无须更新辅助索引中的这个”指针”。也就是说行的位置(实现中通过16K的Page来定位)会随着数据库里数据的修改而发生变化(前面的B+树节点分裂以及Page的分裂),使用聚簇索引就可以保证不管这个主键B+树的节点如何变化,辅助索引树都不受影响。
- 聚簇索引适合用在排序的场合,非聚簇索引不适合
- 取出一定范围数据的时候,使用用聚簇索引
- 二级索引需要两次索引查找,而不是一次才能取到数据,因为存储引擎第一次需要通过二级索引找到索引的叶子节点,从而找到数据的主键,然后在聚簇索引中用主键再次查找索引,再找到数据
- 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户 ID 来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘 I/O。
聚簇索引的劣势
- 维护索引很昂贵,特别是插入新行或者主键被更新导至要分页(page split)的时候。建议在大量插入新行后,选在负载较低的时间段,通过OPTIMIZE TABLE优化表,因为必须被移动的行数据可能造成碎片。使用独享表空间可以弱化碎片
- 表因为使用UUId(随机ID)作为主键,使数据存储稀疏,这就会出现聚簇索引有可能有比全表扫面更慢,所以建议使用int的auto_increment作为主键。主键的值是顺序的,所以 InnoDB 把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB 默认的最大填充因子是页大小的 15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满(二级索引页可能是不一样的)
- 如果主键比较大的话,那辅助索引将会变的更大,因为辅助索引的叶子存储的是主键值;过长的主键值,会导致非叶子节点占用占用更多的物理空间
MYSQL非主键索引的二次查找过程
因为Innodb二级索引存储的是主键,所以通过索引查找时,第一次查询是通过二级索引找到主键值,第二次查询是通过主键在聚簇索引找到对应的行位置。
为什么用 B+ 树做索引而不用哈希表做索引?
哈希表查找效率是O(1),二叉树查找效率是O(logn),那为什么用二叉树不用哈希表呢?
这和业务场景有关。如果只查询一个元素,确实hash表更快,但是数据库经常要选择多条,这时候由于B+树有序,所有value又都保存在叶子节点(操作比B树方便),而且所有叶子节点都有链表连接,所以效率更高。
1、哈希表是把索引字段映射成对应的哈希码然后再存放在对应的位置,这样的话,如果我们要进行模糊查找的话,显然哈希表这种结构是不支持的,只能遍历这个表。而B+树则可以通过最左前缀原则快速找到对应的数据。
2、如果我们要进行范围查找,例如查找ID为100 ~ 400的人,哈希表同样不支持,只能遍历全表。
3、索引字段通过哈希映射成哈希码,如果很多字段都刚好映射到相同值的哈希码的话,那么形成的索引结构将会是一条很长的链表,这样的话,查找的时间就会大大增加。
4、考虑到磁盘操作,磁盘内存有限,很难保证用哈希表可以一口气全读进去,而B+树支持分批处理,同时树的高度比较低,可以提高查重效率。
数据库中文件查找的过程是怎样的?(B树)
B树一般用于文件系统的索引,用于查找文件。
文件系统选择用B树而不用红黑树或有序数组,为什么呢?
首先,文件系统和数据库的索引都是存在硬盘上的,并且如果数据量大的话,不一定能一次性加载到内存中。但是比如用B树,每次只要加载节点上的一两个数就可以了。
所以,数组肯定不能存下,就算红黑树,也要存储大量节点才能找到,所以B树用于文件查找更多。实际上,在内存中,红黑树比B树效率更高。但是如果涉及到磁盘操作,比如读写有限,B树则更优。
B树结构如下:
B-树又称作”多路平衡查找树”
定义:
- 根节点至少包括两个孩子
- 树中每个结点最多含有m个孩子(m>=2)
- 除根节点和叶节点外,其他每个节点至少有ceil(m/2)个孩子(ceil为取上限,举例,1.2和1.5,都是取2)
- 所有叶子节点都位于同一层(即叶子节点高度都相同)
为什么用B+树做索引而不用B树
首先回顾一下B+树的结构:
B+树实际上是B树的变体,其定义和B树不同的地方为:
- 非叶子节点的子树指针与关键字个数相同
- 非叶子节点的子树指针P[i],指向关键字值[K[i],K[i+1])(左闭右开,即可以大于等于K[i],但必须大于K[i+1] )的子树
- 非叶子节点仅用来索引,数据都保存在叶子节点中。所有的数据实际都存储在叶子节点上,所以每一次遍历都必须遍历到叶子节点上。这也使得B+树的层级可以更少,树可以更矮。
- 所有叶子节点均有一个链指针指向下一个叶子节点。搜索的实际是上图中粉色的块的部分。这个链指针主要服务于范围统计,定位到了某个叶指针之后,可以快速横向地去做统计。比如要统计索引>10的,找到了第二个Q之后,直接统计后面所有的Q内容即可。
结论:B+树相比B树更适合用来做存储索引
- B+树的磁盘读写代价更低。B+树内部只存储索引(或者说叶子节点的指针),如果查询多条的时候,B树需要做局部的中序遍历,可能需要不断在父节点和叶子节点之间来回移动,所以B+树的磁盘读写代价更低。
- B+树查询效率更加稳定。因为所有实质内容都存储在根节点上,所以几乎所有数据的查询的时间都是稳定的:O(n)
- B+树更有利于对数据库的扫描。B+树只需要遍历叶子节点就可以实现全部关键字信息的扫描。比如之前提到的,数据库中频繁使用的范围查询,使用B+树查询能够大大增加效率。
主键索引和非主键索引有什么区别?
例如对于下面这个表(其实就是上面的表中增加了一个k字段),且ID是主键。
主键索引和非主键索引的示意图如下:
其中R代表一整行的值。
从图中不难看出,主键索引和非主键索引的区别是:非主键索引的叶子节点存放的是主键的值,而主键索引的叶子节点存放的是整行数据,其中非主键索引也被称为二级索引,而主键索引也被称为聚簇索引。
根据这两种结构我们来进行下查询,看看他们在查询上有什么区别。
- 如果查询语句是 select * from table where ID = 100,即主键查询的方式,则只需要搜索 ID 这棵 B+树。
- 如果查询语句是 select * from table where k = 1,即非主键的查询方式,则先搜索k索引树,得到ID=100,再到ID索引树搜索一次,这个过程也被称为回表。
为什么建议使用主键自增的索引?比如自增id
聚簇索引的数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的。如果主键不是自增id,那么可以想 象,它会干些什么,不断地调整数据的物理地址、分页,当然也有其他一些措施来减少这些操作,但却无法彻底避免。但,如果是自增的,那就简单了,它只需要一 页一页地写,索引结构相对紧凑,磁盘碎片少,效率也高。
因为MyISAM的主索引并非聚簇索引,那么他的数据的物理地址必然是凌乱的,拿到这些物理地址,按照合适的算法进行I/O读取,于是开始不停的寻道不停的旋转。聚簇索引则只需一次I/O。(强烈的对比)
不过,如果涉及到大数据量的排序、全表扫描、count之类的操作的话,还是MyISAM占优势些,因为索引所占空间小,这些操作是需要在内存中完成的。
举个例子,对于这颗主键索引的树
如果我们插入 ID = 650 的一行数据,那么直接在最右边插入就可以了
但是如果插入的是 ID = 350 的一行数据,由于 B+ 树是有序的,那么需要将下面的叶子节点进行移动,腾出位置来插入 ID = 350 的数据,这样就会比较消耗时间,如果刚好 R4 所在的数据页已经满了,需要进行页分裂操作,这样会更加糟糕。
但是,如果我们的主键是自增的,每次插入的 ID 都会比前面的大,那么我们每次只需要在后面插入就行, 不需要移动位置、分裂等操作,这样可以提高性能。也就是为什么建议使用主键自增的索引。
什么是共享锁(S)和排它锁(X)
基本的封锁类型有两种:排它锁(X锁)和共享锁(S锁).
排它锁又称为写锁。若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。这就保证了其它事务在T释放A上的锁之前不能再读取和修改A。
共享锁又称为读锁。若事务T对数据对象A加上S锁,则其它事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这就保证了其它事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
所谓S锁,是事务T对数据A加上S锁时,其他事务只能再对数据A加S锁,而不能加X锁,直到T释放A上的S锁。
若事务T对数据对象A加了S锁,则T就可以对A进行读取,但不能进行更新(S锁因此又称为读锁),在T释放A上的S锁以前,其他事务可以再对A加S锁,但不能加X锁,从而可以读取A,但不能更新A.
什么是数据库的三重加锁协议(三级加锁协议)?
- 1 级封锁协议是:事务T在修改数据R之前必须先对其加X锁,直到事务结束才释放。事务结束包括正常结束(COMMIT)和非正常结束(ROLLBACK)。 1级封锁协议可防止丢失修改,并保证事务T是可恢复的。在1级封锁协议中,如果仅仅是读数据不对其进行修改,是不需要加锁的,所以它不能保证可重复读和不 读”脏”数据。
- 2级封锁协议是:1级封锁协议加上事务T在读取数据R之前必须先对其加S锁,读完后即可释放S锁。2级封锁协议除防止了丢失修改,还可进一步防止读”脏”数据。
- 3级封锁协议是:1级封锁协议加上事务T在读取数据R之前必须先对其加S锁,直到事务结束才释放。3级封锁协议除防止了丢失修改和不读’脏’数据外,还进一步防止了不可重复读。
执行了封锁协议之后,就可以克服数据库操作中的数据不一致所引起的问题。
三段锁协议的应用
共享锁(S锁):共享 (S) 用于不更改或不更新数据的操作(只读操作),如 SELECT 语句。
如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。排他锁(X锁):用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时同一资源进行多重更新。
如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。
具体解释如何用三段锁协议来解决数据不一致的三种情况:
一级封锁协议解决更新丢失
当事务在更新数据的时候给数据加上排他锁
原理:加上排他锁之后,其他事务不能对该数据加上任意锁,在当前事务没有释放锁时其他事务不能进行对该数据的读写操作,只有当前事务释放排他锁之后,才能对该数据进行操作二级封锁协议解决读脏数据
在一级封锁协议的基础上,当事务在读取数据的时候加共享锁,读取完成后释放锁
原理:加入共享锁之后,不能对该数据加排他锁,即其他事务不能进行修改数据。所以此时读取的数据一定是与数据库一致的三级封锁线协议不可重复读
在一级封锁协议的基础上,当事务在读取数据的时候加共享锁,事务结束后释放
原理:加入共享锁之后,不能对该数据加排他锁,即其他事务不能进行修改数据。如果读取完成后就释放,那么其他事务此时可以修改该数据。当延迟到事务结束后释放,其他事务就无法修改该数据了
这三个协议都不能解决幻读,只有串行才能解决幻读。
事务的封锁级别不是越高就越好,随着封锁粒度的增加会影响执行效率。
MyISAM与InnoDB的区别
在5.5版本之前默认采用MyISAM存储引擎,从5.5开始采用InnoDB存储引擎。
1.两者对比
count运算上的区别: 因为MyISAM缓存有表meta-data(行数等),因此在做COUNT(*)时对于一个结构很好 的查询是不需要消耗多少资源的。而对于InnoDB来说,则没有这种缓存
是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行数度比InnoDB类型 更快,但是不提供事务支持。但是InnoDB 提供事务支持事务,外部键等高级数据库功能。 具有事务 (commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。
是否支持外键: MyISAM不支持,而InnoDB支持。
2.两者总结
MyISAM更适合读密集的表,而InnoDB更适合写密集的的表。 在数据库做主从分离的情况下,经常选择MyISAM作 为主库的存储引擎。
一般来说,如果需要事务支持,并且有较高的并发读取频率(MyISAM的表锁的粒度太大,所以当该表写并发量较高 时,要等待的查询就会很多了),InnoDB是不错的选择。如果你的数据量很大(MyISAM支持压缩特性可以减少磁盘 的空间占用),而且不需要支持事务时,MyISAM是好的选择。
InnoDB和MyISAM是使用MySQL时最常用的两种引擎类型,我们重点来看下两者区别。
- 事务和外键
InnoDB支持事务和外键,具有安全性和完整性,适合大量insert或update操作
MyISAM不支持事务和外键,它提供高速存储和检索,适合大量的select查询操作锁机制
InnoDB支持行级锁,锁定指定记彔。基于索引来加锁实现。
MyISAM支持表级锁,锁定整张表。
- 索引结构
InnoDB使用聚集索引(聚簇索引),索引和记彔在一起存储,既缓存索引,也缓存记彔。
MyISAM使用非聚集索引(非聚簇索引),索引和记彔分开。
- 并发处理能力
MyISAM使用表锁,会导致写操作并发率低,读之间并不阻塞,读写阻塞。
InnoDB读写阻塞可以与隔离级别有关,可以采用多版本并发控制(MVCC)来支持高并发
- 存储文件
InnoDB表对应两个文件,一个.frm表结构文件,一个.ibd数据文件。InnoDB表最大支持64TB;
MyISAM表对应三个文件,一个.frm表结构文件,一个MYD表数据文件,一个.MYI索引文件。
从MySQL5.0开始默认限制是256TB。
-两种引擎该如何选择?
-是否需要事务?有,InnoDB
-是否存在并发修改?有,InnoDB
-是否追求快速查询,且数据修改少?是,MyISAM
-在绝大多数情况下,推荐使用InnoDB
MySQL日志文件都有哪些?
1:重做日志(redo log)
2:回滚日志(undo log)
3:二进制日志(binlog)
4:错误日志(errorlog)
5:慢查询日志(slow query log)
6:一般查询日志(general log)
7:中继日志(relay log)
MySQL中redolog和binlog的作用和区别
- Redo Log是属于InnoDB引擎功能; Binlog是属于MySQL Server自带功能,所有引擎都可以使用,并且是以二进制文件记录。
- Redo Log属于物理日志,记录该数据页更新状态内容; Binlog是逻辑日志,记录更新操作语句的原始逻辑。
- Redo Log日志是循环写,日志空间大小是固定;Binlog是追加写入,写完一个写下一个,不会覆盖使用。
- Redo Log作为服务器异常宕机后事务数据自动恢复使用,Binlog可以作为主从复制和数据恢复使用。Binlog没有自动crash-safe能力。
为什么要分库分表?如何实现分库分表?
数据库在架构设计的时候,要遵循两点:可用性和扩展性。
数据量只增不减,历史数据又必须要留存,非常容易成为性能的瓶颈,而要解决这样的数据库瓶颈问题,“读写分离”和缓存往往都不合适,目前比较普遍的方案就是使用NoSQL/NewSQL或者采用分库分表。
使用分库分表时,主要有垂直拆分和水平拆分两种拆分模式,都属于物理空间的拆分。
分库分表方案:只分库、只分表、分库又分表。
- 垂直拆分:由于表数量多导致的单个库大。将表拆分到多个库中。
- 水平拆分:由于表记录多导致的单个库大。将表记录拆分到多个表中。
水平拆分时,需要确定分片键和分片策略,然后使用中间件(ShardingSphere、Mycat 等)操作,还需要考虑分布式主键、分布式事务等。
MySQL中分布式ID生成策略有哪些
UUID COMB(UUID变种) SNOWFLAKE 数据库ID表 Redis生成ID
6.Java相关
Java的char是两个字节,是怎么存Utf-8的字符的?
这个问题看起来很简单,但是详细介绍起来是可以很上台阶的。
- Java char不存 UTF-8 的字节,而是UTF-16的
- Unicode通用字符集占两个字节,例如”中”
- Unicode扩展字符集需要用一对(两个)char来表示,例如某个emoj
- Unicode是字符集,不是编码,作用类似于ASCII码
- Java String的length不是字符数 (而是char数。这个主要针对emoj的情况)
什么是Java的泛型?泛型擦除?泛型标记规范?泛型的限定是什么?写一个简单的泛型程序?(泛型程序记住泛型的泛类标志的位置即可)
一个一个来回答。
什么是泛型?
答:泛型的本质是参数化类型,泛型提供了编译时类型的安全检测机制,该机制允许程序在编译时检测非法的类型。
什么是泛型擦除?
答:在编译阶段采用泛型时加上的类型参数,会被编译器在编译时去掉,这个过程就被称为类型擦除,因此泛型主要用于编译阶段,在编译后生成的Java字节代码文件中不包含泛型中的类型信息。
什么是泛型标记规范?
答:①E
:值Element,在集合中使用,表示在集合中存放的元素。
②T
:指Type,表示Java类,包括基本的类以及自定义类。
③K
:指Key,表示键,例如Map集合中的Key。
④V
:指Value,表示值,例如Map集合中的Value。
⑤N
:指Number,表示数值类型。
⑥?
:表示不确定的Java类型。
Java的重写(overload)和重载(override)?
重载: 发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以 不同,发生在编译时。
重写: 发生在父子类中,方法名、参数列表必须相同,返回值范围小于等于父类,抛出的异常范围小于等于父类, 访问修饰符范围大于等于父类;如果父类方法访问修饰符为 private 则子类就不能重写该方法。
String和StringBuffer、StringBuilder的区别是什么?String为什么是不可变的?
String, StringBuffer和StringBuilder的可见性
实际上String是通过了final关键字修饰的字符数组来保存字符串的,其写法实际上是:private final char value[]
,所以String对象是不可变的。但是StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在这个AbstractStringBuilder类中也是使用字符数组保存字符串 char[] value
但是没有用 final 关键字修饰,所以这两种对象都是可变的。
StringBuilder 与 StringBuffer 的构造方法都是调用父类构造方法也就是 AbstractStringBuilder 实现的,大家可以自 行查阅源码。
AbstractStringBuilder.java源码如下:
1 | abstract class AbstractStringBuilder implements Appendable, CharSequence { |
String, StringBuffer和StringBuilder的线程安全性
String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共 方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对 方法进行加同步锁,所以是非线程安全的。
String, StringBuffer和StringBuilder的性能
每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。 StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是
生成新的对象并改变对象引用。相同情况下使用 StirngBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
String, StringBuffer和StringBuilder的使用总结
如果只需要操作少量字符串:用String
如果在单线程操作字符串缓冲区下的大量数据:用StringBuilder(性能提升10%-15%,但线程不安全)
如果多线程操作字符串缓冲区下的大量数据:用StringBuffer(线程安全)
==与equals
==:它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象。(基本数据类型==比较的是 值,引用数据类型==比较的是内存地址)
equals:它的作用也是判断两个对象是否相等。但它一般有两种使用情况:
- 情况1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。
- 情况2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来自定义方法来判断两个对象是否内容相等;若它们的内容相 等,则返回 true (即,认为这两个对象相等)。
举个例子:
1 | public class test { |
说明:
- String 中的 equals 方法是被重写过的,因为 object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是对象的值。
- 当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有 就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。
equals()和hashCode()有什么联系?
简介hashCode()
首先,hashCode()的作用是获取哈希码,也称为散列码;它实际上是返回一个int整数。
这个哈希码的作用是确定该对象在 哈希表中的索引位置。hashCode() 定义在JDK的Object.java中,这就意味着Java中的任何类都包含有hashCode() 函 数。另外需要注意的是: Object 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法通常用来将对象的 内存地址 转换为整数之后返回。
方法为:public native int hashCode()
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码! (可以快速找到所需要的对象)
为什么要有hashCode()?
我们可以举个例子,以”HashSet如何进行重复检查”为例来说明为什么要有hashCode:
当你把对象加入HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他已经加 入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相 同hashcode值的对象,这时会调用equals()方法来检查hashcode相等的对象是否真的相同。如果两者相同, HashSet就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。(摘自Java启蒙书《Head fist java》第二版)。这样我们就大大减少了equals的次数,相应就大大提高了执行速度。
hashCode()与equals()的相关规定
- 如果两个对象相等,则它们的hashcode一定相等
- 两个对象相等,对两个对象分别调用equals方法都返回true
- 两个对象的hashcode值相等,它们不一定相等
- 若equals()方法被覆盖过,则hashCode()方法也必须被重写覆盖
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何 都不会相等(即使这两个对象指向相同的数据)
为什么两个对象有相同的hashCode值,它们却不一定是相等的?
下面内容摘自《Head First Java》
因为hashCode() 所使用的杂凑算法也许刚好会让多个对象传回相同的杂凑值。越糟糕的杂凑算法越容易碰撞,但这 也与数据值域分布的特性有关(所谓碰撞也就是指的是不同的对象得到相同的 hashCode)。
我们刚刚也提到了 HashSet,如果 HashSet 在对比的时候,同样的 hashcode 有多个对象,它会使用 equals() 来判断 是否真的相同。也就是说 hashcode 只是用来缩小查找成本。
简述final、finally和finalize的区别
final关键字主要用在三个地方:变量、方法、类。详细描述一下的话有三点:
- 对于一个final变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的 变量,则在对其初始化之后便不能再让其指向另一个对象。
- 当用final修饰一个类时,表明这个类不能被继承。final类中的所有成员方法都会被隐式地指定为final方法。
- 使用final方法的原因有两个。第一个原因是把方法锁定,以防任何继承类修改它的含义;第二个原因是效率。 在早期的Java实现版本中,会将final方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的 任何性能提升(现在的Java版本已经不需要使用final方法进行这些优化了)。类中所有的private方法都隐式地 指定为fianl。
finally用于try-catch代码块中,无论是否发生异常最后都将执行,作用是释放资源。
finalize是Object类的方法,在对象垃圾回收之前将调用一次,一般用于资源的释放。
Java的Object类中有哪些常见方法?分别有什么作用?
Object类是一个特殊的类,其是所有类的父类。在类加载的双亲委派机制下,如果程序员自己定义重写了一个Object中的方法,JVM会加载Object中的类而不是加载程序员自己写的,这防止了核心API被篡改。
具体来说Object类有11个方法:getClass()、hasCode()、equals(Object obj)、clone()、toString()、notify()、notifyAll、wait(long timeout)、wait(long timeout, int nanos)、wait()、finalize()
具体内容如下:
1 | public final native Class<?> getClass()//native方法,用于返回当前运行时对象的Class对象,使用了 final关键字修饰,故不允许子类重写。 |
Java的异常处理
整体的思维导图概览如下:
在 Java 中,所有的异常都有一个共同的祖先java.lang包中的 Throwable类。Throwable: 有两个重要的子类: Exception(异常) 和 Error(错误) ,二者都是 Java 异常处理的重要子类,各自都包含大量子类。
Error(错误):是程序无法处理的错误,表示运行应用程序中较严重问题。Error指Java程序运行错误,出现Error通常是因为系统的内部错误或资源耗尽,Error不能在运行过程中被动态处理,如果程序运行中出现Error,系统只能记录错误的原因和安全终止。
大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,Java虚拟机运行错误(Virtual MachineError),当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。这些异常发生时,Java虚拟机(JVM)一 般会选择线程终止。
这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,如Java虚拟机运行错误(Virtual MachineError)、类定义错误(NoClassDefFoundError)等。这些错误是不可查的,因为它们在应用程序的控制和 处理能力之 外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错 误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。
Exception(异常):是程序本身可以处理的异常。Exception指Java程序运行异常,即运行中发生了不期望的情况,分为RuntimeException和CheckedException(上图中的 IOException)。RuntimeException指在Java虚拟机正常运行期间抛出的异常,可以被捕获并处理,例如空指针异常,数组越界等。CheckedException指编译阶段强制要求捕获并处理的异常,例如IO异常,SQL异常等。
Exception 类有一个重要的子类 RuntimeException。 RuntimeException 异常由Java虚拟机抛出。NullPointerException(要访问的变量没有引用任何对象时,抛出该 异常)、ArithmeticException(算术运算异常,一个整数除以0时,抛出该异常)和 ArrayIndexOutOfBoundsException (下标越界异常)。
注意:异常和错误的区别:异常能被程序本身可以处理,错误是无法处理。
Throwable类常用方法如下:
- public string getMessage():返回异常发生时的详细信息
- public string toString():返回异常发生时的简要描述
- public string getLocalizedMessage():返回异常对象的本地化信息。使用Throwable的子类覆盖这个方法,可 以声称本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()返回的结果相同
- public void printStackTrace():在控制台上打印Throwable对象封装的异常信息
Java异常处理的 try-catch-finally
- try 块:用于捕获异常。其后可接零个或多个catch块,如果没有catch块,则必须跟一个finally块
- catch 块:用于处理try捕获到的异常。
- finally 块:无论是否捕获或处理异常,finally块里的语句都会被执行。当在try块或catch块中遇到return语句 时,finally语句块将在方法返回之前被执行。
finally块不会被执行的四种特殊情况
- 在finally语句块中发生了异常。
- 在前面的代码中用了System.exit()退出程序。
- 程序所在的线程死亡。
- 关闭CPU
Java异常处理的方式
- 抛出异常:遇到异常不进行具体处理,而是将异常抛出给调用者,由调用者根据情况处理。抛出异常有2种形式,一种是throws,作用在方法上,一种是throw,作用在方法内。
- 使用try/catch进行异常的捕获处理,try中发生的异常会被catch代码块捕获,根据情况进行处理,如果有finally代码块无论是否发生异常都会执行,一般用于释放资源,JDK1.7开始可以将资源定义在try代码块中自动释放减少代码。
详解 throw 和 throws的区别
throws 用在函数上,后面跟的是异常类,可以跟多个;
语法:(修饰符)(方法名)([参数列表])[throws(异常类)]{……}
public void doA(int a) throws Exception1,Exception3{……}
throw 用在函数内,后面跟的是异常对象。
throws E1,E2,E3只是告诉程序这个方法可能会抛出这些异常,方法的调用者可能要处理这些异常,而这些异常E1,E2,E3可能是该函数体产生的。
throw则是明确了这个地方要抛出这个异常。
结合来看:
1 | void doA(int a) throws IOException,{ |
throws 用来声明异常,让调用者知道该功能可能会出现的问题(比如上方的 IO 异常),可以给出预先的处理方式;
throw 抛出具体的问题对象,执行到 throw,功能就已经结束了,跳转到调用者,并将具体的问题对象抛给调用者。
也就是说 throw 语句独立存在时,下面不要定义其他语句,因为执行不到。
概括:
throws 表示出现异常的一种可能性,并不一定会发生这些异常;
throw 则是抛出了异常,执行 throw 则一定抛出了某种异常对象。
什么是Java的反射?简述其优缺点?
反射的基本概念:
答:①在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为Java的反射机制。
② 优点是运行时动态获取类的全部信息,缺点是破坏了类的封装性,泛型的约束性。
③反射是框架的核心灵魂,动态代理设计模式采用了反射机制,还有 Spring、Hibernate 等框架也大量使用到了反射机制。
获得class对象有哪几种方式?能通过Class对象获取类的哪些信息?
答:①通过类名.class
②通过对象.getClass()
③通过Class.forName(类的全限名);
④可以通过Class对象获取类的成员变量,方法或构造器。带declared的获取方法可以获取到类的一个或全部成员变量,方法,构造器,不带declared的方法只能获取到类的public修饰的成员变量、方法或构造器,包括父类public修饰的成员变量、方法或构造器。
接口和抽象类的区别是什么
- 接口的方法默认是public, 所有方法在接口中不能有实现(Java 8 开始接口方法可以有默认实现),抽象类可以有非抽象的方法
- 接口中的实例变量默认是 final 类型的,而抽象类中则不一定
- 一个类可以实现多个接口,但多只能实现一个抽象类
- 一个类实现接口的话要实现接口的所有方法,而抽象类不一定
- 接口不能用 new 实例化,但可以声明,但是必须引用一个实现该接口的对象 从设计层面来说,抽象类是对类的抽 象,是一种模板设计,接口是行为的抽象,是一种行为的规范。
备注:在JDK8中,接口也可以定义静态方法,可以直接用接口名调用。实现类和实现是不可以调用的。如果同时实现 两个接口,接口中定义了一样的默认方法,必须重写,不然会报错。(详见 issue:https://github.com/Snailclimb/JavaGuide/issues/146)
Java中ThreadLocal是什么?谈谈对它的理解?
关于ThreadLocal的用法,可以变换出现的问题是:在多线程环境下,如何防止自己的变量被其它线程篡改?
所以,看出来了吧,ThreadLocal的作用就是在多线程环境下防止自己的变量被其他线程篡改,在多线程环境下去保证成员变量的安全。
ThreadLocal从数据结构上来讲有点像HashMap,它可以保存”key:value”键值对,但是一个ThreadLocal只能保存一个,而且各个线程的数据互相之间不干扰。
- set需要首先获得当前线程对象Thread,然后取出当前线程对象的成员变量ThreadLocalMap;
如果ThreadLocalMap存在,那么进行KEY/VALUE设置,KEY就是ThreadLocal;
如果ThreadLocalMap没有,那么创建一个;
说白了,当前线程中存在一个Map变量,KEY是ThreadLocal,VALUE是你设置的值。
Java的线程状态有哪些?它是如何工作的?(重要**)
线程(Thread)是并发编程的基础,它是程序执行的最小单元。一个进程可以包含多个线程,多线程可以共享一块内存空间和一组系统资源,因此线程之间的切换更节省资源,也因为如此,线程常被成为轻量级的进程。
对线程的掌握,可以有效地提高程序整体运行效率。
Java线程状态在JDK1.5后一共有六个
Java线程状态在JDK1.5后以枚举的方式定义在Thread的源码中,一共有六个状态
- NEW新建状态,线程被创建出来,但尚未启动时的线程状态;
- RUNNABLE,就绪状态,表示可以运行的线程状态,它可能正在运行,或者是在排队等待操作系统给它分配 CPU 资源;
- BLOCKED,阻塞等待锁的线程状态,表示处于阻塞状态的线程正在等待监视器锁,比如等待执行 synchronized 代码块或者使用 synchronized 标记的方法;
- WAITING,等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作,比如,一个线程调用了Object.wait()方法,那它就在等待另一个线程调用Object.notify()或Objec.botifyAll()方法;
- TIMED_WAITING,计时等待状态,和等待状态(WAITING)类似,它只是多了超时时间,比如调用了有超时时间设置的方法 Object.wait(long timeout) 和 Thread.join(long timeout) 等这些方法时,它才会进入此状态;
- TERMINATED,终止状态,表示线程已经执行完成
线程如何工作的?——线程工作模式
线程的工作模式是,首先先要创建线程并指定线程需要执行的业务方法,然后再调用线程的start()方法,此时线程就从NEW(新建)状态变成了RUNNABLE(就绪)状态,此时线程会判断要执行的方法中有没有 synchronized 同步代码块,如果有并且其他线程也在使用此锁,那么线程就会变为 BLOCKED(阻塞等待)状态,当其他线程使用完此锁之后,线程会继续执行剩余的方法。
当遇到Object.wait()或Thread.join()方法时,线程会变为WAITING(等待状态)状态,如果是带了超时时间的等待方法,那么线程会进入TIMED_WAITING(计时等待)状态,当有其他线程执行了 notify() 或 notifyAll() 方法之后,线程被唤醒继续执行剩余的业务方法,直到方法执行完成为止,此时整个线程的流程就执行完了,执行流程如下图所示:
Java创建新线程有几种方式?
一般来说有四种方式:
- 继承Thread类(真正意义上的线程类),这个类是Runnable接口的实现,继承之后重写run()方法,在这个方法里写出来该线程具体要完成的任务。
- 自己实现Runnable接口,重写里面的run方法,创建线程类
- 使用Executor框架创建线程池(需要注意,阿里巴巴的《Java开发手册》不允许用Executors区创建线程池,而要用ThreadPoolExecutor的方式,这样可以更加明确线程池的运行规则,规避资源耗尽的风险)。Executor框架是JUC里提供的线程池实现的。
- 通过Callable和Future创建线程
虽然最常用的是四种,但是继承Thread类和实现Runnable接口是最常用的。而且更加提倡的是使用实现Runnable接口的方式。实现Runnable接口的优势:
①避免点继承的局限,一个类可以继承多个接口。
②适合于资源的共享
BLOCKED(阻塞等待)和WAITING(等待)有什么区别?
虽然BLOCKED和WAITING都有等待的含义,但二者有着本质的区别,首先它们状态形成的调用方法不同,其次BLOCKED可以理解为当前线程还处于活跃状态,只是在阻塞等待其他线程使用完某个锁资源;而WAITING则是因为自身调用了Object.wait()或着是Thread.join()又或者是LockSupport.park()而进入等待状态,只能等待其他线程执行某个特定的动作才能被继续唤醒,比如当线程因为调用了 Object.wait() 而进入 WAITING 状态之后,则需要等待另一个线程执行Object.notify() 或 Object.notifyAll() 才能被唤醒。
start()方法和run()方法有什么区别?
主要区别有三点:
首先从 Thread 源码来看,start() 方法属于 Thread 自身的方法,并且使用了 synchronized 来保证线程安全。run() 方法为 Runnable 的抽象方法,必须由调用类重写此方法,重写的 run() 方法其实就是此线程要执行的业务方法。
从执行的效果来说,start() 方法可以开启多线程,让线程从 NEW 状态转换成 RUNNABLE 状态,而 run() 方法只是一个普通的方法。
它们可调用的次数不同,start()方法不能被多次调用,否则会抛出java.lang.IllegalStateException;而run()方法可以进行多次调用,因为它只是一个普通的方法而已。
wait() 和 sleep()有什么区别?
主要区别有四点:
- wait()属于Object类,sleep()属于Thread类;
- wait会释放锁对象,而sleep不会;
- 使用的位置不同,wait()需要在同步块中使用,sleep()可以在任意地方;
- sleep()需要捕获异常,而wait()不需要
线程的优先级有什么用?如何设置优先级?
Thread源码中与线程优先级相关的属性有3个:
1 | //线程可以拥有的最小优先级 |
线程的优先级可以理解为线程抢占 CPU 时间片的概率,优先级越高的线程优先执行的概率就越大,但并不能保证优先级高的线程一定先执行。
在程序中我们可以通过Thread.setPriority()来设置优先级。
线程常用方法有哪些?
join()
在一个线程中调用join(),会让当前线程交出执行权给other线程,直到other线程执行完或者过了超时时间之后再继续执行当前线程。
在源码中可以看到,join()方法底层是通过wait()方法实现的。
举个例子:
1 | public class Test { |
这段代码的执行结果:
1 | 主线程睡眠:1秒 |
可以看到,不使用join()的时候,主线程和子线程会交替执行。
然后加入join()方法:
1 | public class Test { |
执行结果:
1 | 子线程睡眠:1秒 |
可以看到用了join()方法之后,子线程会先join
进来执行4秒,之后才会执行主线程。
yield()
通过Thread()源码可以知道yield()为本地方法,也就是说yield()是由C/C++实现的。
yield() 方法表示给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。
执行一段包含yield()方法的代码之后会发现,每次执行的结果都不相同,这是因为yield()执行非常不稳定,线程调度器不一定会采纳yield()出让CPU使用权的建议,从而导致了这样的结果。
volatile的作用
在很多的开源中间件系统的源码里,大量的使用了volatile,每一个开源中间件系统,或者是大数据系统,都多线程并发。
volatile的作用主要是两个:保证可见性(这里指主内存与工作内存间的可见性),防止指令重排(指令重排也会导致可见性问题,防止指令重排也可以称作有序性)
什么是可见性?
意思就是说,在多线程环境下,某个共享变量如果被其中一个线程给修改了,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取.
如何保证内存可见性?
答:volatile修饰的变量保证其每个写操作后都强制更新到主内存,每个读操作都到主内存中更新,具体的话是在JVM层面,在修饰的变量前后加关键字。
NOTE:Java内存模型规定所有的变量都是存在(主内存)主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
由于java中的每个线程有自己的工作空间,这种工作空间相当于上面所说的高速缓存,因此多个线程在处理一个共享变量的时候,就会出现线程安全问题。
所谓共享变量,是能够被多个线程访问到的变量。在java中共享变量包括实例变量,静态变量,数组元素。他们都被存放在堆内存中。
什么是指令重排?
当我们把代码写好之后,虚拟机不一定会按照我们写的代码的顺序来执行。例如对于下面的两句代码:
1 | int a = 1; |
对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。
对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。
但是也有可能会有影响的例子,比如线程T2需要变量flag为true才能执行,而flag只有在T1中才会被置为true。这里如果发生指令重排,T1还没执行完的时候T2就感知到了flag为true,那么程序执行逻辑就出错了,所以不能让JVM随意重排指令。
指令重排是JVM做出的优化,这里Java的双check的单例模式也利用了volatile来保证不重排。
volatile能完全保证一个变量的线程安全么?
volatile好像很有用,不但能够保证变量可见性,还能防止指令重排。
那么,它真的能够保证一个变量在多线程环境下都能被正确的使用吗?
答案是否定的。原因是Java里面的运算并非是原子操作,volatile也不能是原子性的。虽然说有些极端特殊的情况下有保证原子性的效果,比如,oracle,64位的long的数字进行操作,volatile可以保证原子性。但是这个很不具备普遍性,不能说volatile能够保证原子性。
原子性\原子操作
原子操作:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
也就是说,处理器要嘛把这组操作全部执行完,中间不允许被其他操作所打断,要嘛这组操作不要执行。
刚才说Java里面的运行并非是原子操作。我举个例子,例如这句代码
1 | int a = b + 1; |
处理器在处理代码的时候,需要处理以下三个操作:
从内存中读取b的值。
进行a = b + 1这个运算
把a的值写回到内存中
而这三个操作处理器是不一定就会连续执行的,有可能执行了第一个操作之后,处理器就跑去执行别的操作的。
什么情况下volatile可以保证线程安全
刚才虽然说,volatile关键字不一定能够保证线程安全的问题,其实,在大多数情况下volatile还是可以保证变量的线程安全问题的。所以,在满足以下两个条件的情况下,volatile就能保证变量的线程安全问题:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
volatile原理(深入到内存屏障)
最后来看原理,因为最复杂(吧)。
volatile底层原理是怎样的?如何实现保证可见性的呢?如何实现保证有序性的呢?
简单来说,如果我用volatile修饰某个变量,那么这个变量在读写前后会加入一些屏障,这些屏障能够保证代码不会对volatile修饰的读写部分进行指令重排。
此外,还需要记住lock指令。
(1)lock指令:volatile保证可见性
对volatile修饰的变量,执行写操作的时候,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,随时check自己本地缓存中的数据是否被别人修改。
如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了。
lock前缀指令 + MESI缓存一致性协议。
(2)内存屏障:volatile禁止指令重排序
volatille是如何保证有序性的?加了volatile的变量,可以保证前后的一些代码不会被指令重排,这个是如何做到的呢?指令重排是怎么回事,volatile就不会指令重排,简单介绍一下,实际内存屏障机制是非常非常复杂的:
这里介绍三种屏障:Load屏障、Store屏障、LoadStore屏障
1 | Load1: |
LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
1 | Store1: |
StoreStore屏障
1 | Store2: |
StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
volatile的作用是什么呢?
volatile variable = 1
this.variable = 2 => store操作
int localVariable = this.variable => load操作
对于volatile修改变量的读写操作,都会加入内存屏障
每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排
每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排
线程池原理介绍
线程池刚创建时是没有线程的,并且里面包含了一个任务队列。
可以举一个例子,如果用基于ThreadPoolExecutor创建的newFixedThreadPool()
线程池,代码如下:
1 | ExecutorService threadPool = Executors.newFixedThreadPool(3) -> 3: corePoolSize |
其阻塞队列是无界阻塞队列,即队列的大小是无穷大的。
其实,这个newFixedThreadPool()
线程池是很常用的,其构造函数其实就是:
1 | return new ThreadPoolExecutor(nThreads, |
其默认设置的是corePoolSize
和maximumPoolSize
大小相同。
当有任务过来时,会去判断线程池中的线程数量是否小于corePoolSize
,如果是,就去创建一个线程去执行这个任务,任务执行完成以后,这个线程就会阻塞在队列头部,继续等待下一个任务,只要下一个任务能被当前线程池里的线程执行,那么就会直接被使用;如果线程池中的线程数量已经等于corePoolSize时,就将当前任务放入任务队列,阻塞在任务队列的线程就会去执行这个任务。
如下图所示(注意此时线程池大小为3):
ThreadPoolExecutor介绍
线程池的使用必须要通过ThreadPoolExecutor的方式来创建,这样才可以更加明确线程池的运行规则,规避资源耗尽的风险。
ThreadPoolExecutor有七大核心参数,包括核心线程数和最大线程数之间的区别,当线程池的任务队列没有可用空间且线程池的线程数量已经达到了最大线程数时,则会执行拒绝策略,Java 自动的拒绝策略有 4 种,用户也可以通过重写rejectedExecution()来自定义拒绝策略,我们还可以通过重写beforeExecute()和afterExecute()来实现ThreadPoolExecutor的扩展功能。
线程池——ThreadPoolExecutor参数含义及源码执行流程?
线程池是为了避免线程频繁的创建和销毁带来的性能消耗,而建立的一种池化技术,它是把已创建的线程放入“池”中,而不是在线程执行完任务后将其销毁。这样当再有任务来临时就可以重用已有的线程,无需等待创建的过程,这样就可以有效提高程序的响应速度。
而说到线程池,现在一定离不开ThreadPoolExecutor
,现在不推荐用Executors
去创建线程池了。阿里巴巴的《Java开发手册》中这样规定创建线程池的方式:
1 | 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的读者更加明确线程池的运行规则,规避资源耗尽的风险。 |
ThreadPoolExecutor一共有七个核心参数
第1个参数:corePoolSize
表示线程池的常驻核心线程数(即在没有任务需要执行的时候线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程)。如果设置为0,则表示在没有任何任务时,销毁线程池;如果大于0,即使没有任务时也会保证线程池的线程数量等于此值。但需要注意,此值如果设置的比较小,则会频繁的创建和销毁线程;如果设置的比较大,则会浪费系统资源,所以开发者需要根据自己的实际业务来调整此值。
第2个参数:maximumPoolSize
表示线程池在任务最多时,最大可以创建的线程数(线程池中的当前线程数数目不会超过该值,如果队列中任务已满,而且当前线程数小于maximumPoolSize
,那么会创建新的线程来执行任务)。官方规定此值必须大于0,也必须大于等于corePoolSize,此值只有在任务比较多,且不能存放在任务队列时,才会用到。
也就是说,线程池的线程数量最小不小于corePoolSize
,但是最大不大于maximumPoolSize
第3个参数:keepAliveTime
表示线程的存活时间,当线程池空闲时并且超过了此时间,多余的线程就会销毁,直到线程池中的线程数量销毁的等于corePoolSize为止,如果 maximumPoolSize 等于 corePoolSize,那么线程池在空闲的时候也不会销毁任何线程。
第 4 个参数:unit
表示存活时间的单位,它是配合 keepAliveTime 参数共同使用的。
第 5 个参数:workQueue
表示线程池执行的任务队列,当线程池的所有线程都在处理任务时,如果来了新任务就会缓存到此任务队列中排队等待执行。
第 6 个参数:threadFactory
表示线程的创建工厂,此参数一般用的比较少,我们通常在创建线程池时不指定此参数,它会使用默认的线程创建工厂的方法来创建线程。
第 7 个参数:RejectedExecutionHandler
表示指定线程池的拒绝策略。很可能发生的一种情况是,我的任务队列里有特别多的队列,然后线程池现在已经装满了maximumPoolSize
的数量,那么此时我们已经达到了承受的上限,再来任务就需要拒绝了。即:当线程池的任务已经在缓存队列workQueue中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。
线程池(ThreadPoolExecutor)执行流程
通过executor()
方法开始执行,如果当前工作线程数(workerCount)小于核心线程数,会创建新的线程执行这个任务。
然后检查线程池是否处于运行状态,是的话把任务添加到队列。如果线程池处于非运行状态,而且处于爆满,并且尝试新启动一个线程失败了,那么执行拒绝策略。
重点方法:addWorker(Runnable firstTask, boolean core)
两个方法参数如下:
firstTask,线程应首先运行的任务,如果没有则可以设置为 null;
core,判断是否可以创建线程的阀值(最大值),如果等于 true 则表示使用 corePoolSize 作为阀值,false 则表示使用 maximumPoolSize 作为阀值。
具体流程可以参考这张图:
使用Executors返回线程池对象的弊端
主要两点:
FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
线程池的拒绝策略
当线程池中的任务队列已经被存满,再有任务添加时会先判断当前线程池中的线程数是否大于等于线程池的最大值,如果是,则会触发线程池的拒绝策略,即之前七个参数中的RejectedExcecutionHandler
。
Java 自带的拒绝策略有 4 种:
- Abort (Policy),终止策略,线程池会抛出异常并终止执行,它是默认的拒绝策略;
- CallerRuns (Policy),把任务交给当前线程来执行
- Discard (Policy),忽略此任务(最新的任务);
- DiscardOldest (Policy),忽略最早的任务(最先加入队列的任务)。
自定义拒绝策略
自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可。
ThreadPoolExecutor扩展
ThreadPoolExecutor的扩展主要是通过重写它的beforeExecute()和afterExecute()方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间。
展示代码暂时略。
如果在线程池中使用无界阻塞队列会发生什么问题?
这个问题的另外一个问法:在远程服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
考察你对线程池工作原理是否理解,线程处理任务、队列长度等。
实际上,每个线程在执行任务的时候都会调用远程服务。如果某个服务总是异常,那么会导致你这个线程总是调用失败,调用时间会超时,导致整个系统卡住,然后挤压的任务越来越多,最后会导致内存飙升,甚至可能导致OOM,内存泄漏。
如果线程池的队列满了,会发生什么?
这道题还是考察你对线程池原理是否理解。
如果你使用有界队列,那么可以避免上述说的内存溢出问题。
但是如果我们还是使用无阻塞队列,设置maximumPoolSize
为Integer.MAX_VALUE
了,那么如果某个时刻来的请求太多太多了,虽然无限制的不停的创建额外的线程出来,最后一台机器上,有几千个线程,甚至是几万个线程,但是每个线程都有自己的栈内存,占用一定的内存资源,会导致内存资源耗尽,系统也会崩溃掉。
即使内存没有崩溃,会导致你的机器cpu load,负载特别高。
建议:自定义一个reject策略,如果线程池无法执行更多的任务了,此时你可以把这个任务信息持久化写入磁盘里去,后台专门启动一个线程,后续等待你的线程池的工作负载降低了,他可以慢慢地从磁盘里读取之前持久化的任务,重新提交到线程池里去执行。
如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
首先,必然会导致线程池里的积压的任务都会丢失。
如果你提交一个任务到线程池里去,在提交之前,你需要先在数据库里插入这个任务的信息,更新他的状态:未提交、已提交、已完成。提交成功之后,更新他的状态是 已提交 状态。
系统重启,你可以自己定义一个后台线程,这个后台线程需要去扫描数据库里的未提交和已提交状态的任务,可以把任务的信息读取出来,重新提交到线程池里去,继续进行执行。
搞懂synchronized(从偏量锁到重量级锁)
从一个例子展开基础用法
接触过线程安全的同学想必都使用过synchronized这个关键字,在java同步代码快中,synchronized的使用方式无非有两个:
- 通过对一个对象进行加锁来实现同步,如下面代码。
synchronized(lockObject){ //代码}
- 对一个方法进行synchronized声明,进而对一个方法进行加锁来实现同步。如下面代码
1 | public synchornized void test(){ |
下面会讲到,实际上在JVM层面来看,用到了monitor对象,字节码会用到monitorenter
和monitorexit
指令。
首先加锁,使用monitorenter
指令。即刚进入到synchronized关键字的范围内的时候,会使用monitorenter
指令。
每个对象都有一个关联的monitor,比如一个对象实例就有一个monitor,一个类的Class对象也有一个monitor,如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁
他里面的原理和思路大概是这样的,monitor里面有一个计数器,从0开始的。如果一个线程要获取monitor的锁,就看看他的计数器是不是0,如果是0的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加1。
如果一个线程第一次从synchronized那里获取到了myObject对象的monitor的锁,计数器会加1,然后二次到synchronized那里,会再次获取myObject对象的monitor锁,这个就是重入加锁了,然后计数器会再次加1,变成2。这个时候,其他的线程在第一次synchronized那里,会发现说myObject对象的monitor锁的计数器是大于0的,意味着被别人加锁了,然后此时线程就会进入block阻塞状态,什么都干不了,就是等着获取锁。
释放锁,使用monitorexit
指令,即如果出了synchronized修饰的代码片段的范围,就会有一个monitorexit
指令,在底层。此时获取锁的线程就会对那个对象的monitor的计数器减1,如果有多次重入加锁就会对应多次减1,直到最后,计数器是0。
如下图所示:
但这里需要指出的是,无论是对一个对象进行加锁还是对一个方法进行加锁,实际上,都是对对象进行加锁。
也就是说,对于方式2,实际上虚拟机会根据synchronized修饰的是实例方法还是类方法,去取对应的实例对象或者Class对象来进行加锁。
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者 代码块在任意时刻只能有一个线程执行。
另外,在 Java 早期版本(JDK1.6之前)中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor,在Java中每个对象都隐式包含一个monitor(监视器)对象,加锁的过程其实就是竞争monitor的过程,当线程进入字节码monitorenter指令之后,线程将持有monitor对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。 )是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程, 都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要 相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对 锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
synchronized和ReentrantLock,两者的实现原理?两者的区别?
在JDK1.5之前共享对象的协调机制只有synchronized和volatile,在JDK1.5中增加了新的机制ReentrantLock,该机制的诞生并不是为了替代synchronized而是在 synchronized 不适用的情况下,提供一种可以选择的高级功能。
注意synchronized:synchronized关键字加到非 static 静态 方法上是给对象实例上锁。另外需要注意的是:尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓冲功能!
synchronized和ReentrantLock原理
synchronized 属于独占式悲观锁,是通过 JVM 隐式实现的.
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者 代码块在任意时刻只能有一个线程执行。
synchronized的一个例子就是单例模式的双检查模式中对类加锁的操作。
ReentrantLock是Lock的默认实现方式之一,默认也是悲观锁,它是基于AQS(AbstractQueuedSynchronizer,队列同步器)实现的,它默认是通过非公平锁实现的,在它的内部有一个 state 的状态字段用于表示锁是否被占用,如果是 0 则表示锁未被占用,此时线程就可以把 state 改为 1,并成功获得锁,而其他未获得锁的线程只能去排队等待获取锁资源。
synchronized和ReentrantLock共同点与区别
相同点
两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时 这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
两者都是悲观锁,都属于独占锁的实现
Lock 只是一个顶层抽象接口,并没有实现,也没有规定是乐观锁还是悲观锁实现规则。而 ReentrantLock 作为 Lock 的一种实现,是悲观锁。(注:ReentrantReadWriteLock 的提供了一种乐观锁的实现,但是这个不太熟的话建议面试别说)
不同点
① synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多 优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock 是 JDK 层面实现的(也就 是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看 它是如何实现的。
说白了,Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,前者的实现是比较难见到的,后者有直接的源码可供阅读。
② ReenTrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReenTrantLock增加了一些高级功能。主要来说主要有三点:
- 等待可中断;
- 可实现公平锁;
- 可实现选择性通知(锁可以绑定多个条件)
ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也 就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁(但是两者默认都是非公平锁,因为性能更好)。所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是非公平的,可以通过 ReenTrantLock类的
ReentrantLock(boolean fair)
构造方法来制定是否是公平的。synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也 可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很 好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视 器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合 Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而 synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的 signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
“synchronized和ReentrantLock共同点与区别”的典型回答(重要)
synchronized 属于独占式悲观锁,是通过 JVM 隐式实现的,synchronized 只允许同一时刻只有一个线程操作资源。
在Java中每个对象都隐式包含一个monitor(监视器)对象,加锁的过程其实就是竞争monitor的过程,当线程进入字节码monitorenter指令之后,线程将持有monitor对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
ReentrantLock 是 Lock 的默认实现方式之一,注意ReentrantLock也是独占式悲观锁,它是基于 AQS(Abstract Queued Synchronizer,队列同步器)实现的,它默认是通过非公平锁实现的,在它的内部有一个 state 的状态字段用于表示锁是否被占用,如果是 0 则表示锁未被占用,此时线程就可以把 state 改为 1,并成功获得锁,而其他未获得锁的线程只能去排队等待获取锁资源。
synchronized和ReentrantLock都提供了锁的功能,具备互斥性和不可见性。在JDK1.5中synchronized的性能远远低于ReentrantLock,但在 JDK 1.6 之后 synchronized 的性能略低于 ReentrantLock,它的区别如下(四点):
- synchronized是JVM隐式实现的,而ReentrantLock是Java语言提供的API;ReentrantLock可设置为公平锁,而synchronized却不行;
- ReentrantLock只能修饰代码块,而synchronized可以用于修饰方法、修饰代码块等;
- ReentrantLock需要手动加锁和释放锁,如果忘记释放锁,则会造成资源被永久占用,而 synchronized 无需手动释放锁——即Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
- ReentrantLock 可以知道是否成功获得了锁,而 synchronized 却不行。
- synchronized只有一个等待队列,而lock调用newCondition()可以产生多个等待队列
公平锁 VS 非公平锁
先介绍一下什么是公平锁和非公平锁。
公平锁的含义是线程需要按照请求的顺序来获得锁;而非公平锁则允许“插队”的情况存在,所谓的“插队”指的是,线程在发送请求的同时该锁的状态恰好变成了可用,那么此线程就可以跳过队列中所有排队的线程直接拥有该锁。
而公平锁由于有挂起和恢复所以存在一定的开销,因此性能不如非公平锁,所以 ReentrantLock 和 synchronized 默认都是非公平锁的实现方式。
ReentrantLock详细介绍(源码分析,主要针对加锁和解锁的流程)
本课时从源码出发来解密 ReentrantLock 的具体实现细节,首先来看 ReentrantLock 的两个构造函数:
1 | public ReentrantLock() { |
无参的构造函数创建了一个非公平锁,用户也可以根据第二个构造函数,设置一个 boolean 类型的值,来决定是否使用公平锁来实现线程的调度。
ReentrantLock默认使用lock()来获取锁的,并通过unlock()释放锁,样例代码如下:
1 | Lock lock = new ReentrantLock(); |
先来重点看一下lock()的过程:
ReentrantLock中的lock()是通过sync.lock()实现的,但Sync类中的lock()是一个抽象方法,需要子类NonfairSync或FairSync去实现,NonfairSync中的lock()源码如下:
1 | final void lock() { |
FairSync中的lock()源码如下:
1 | final void lock() { |
可以看出非公平锁比公平锁只是多了一行 compareAndSetState 方法,该方法是尝试将 state 值由 0 置换为 1,如果设置成功的话,则说明当前没有其他线程持有该锁,不用再去排队了,可直接占用该锁,否则,则需要通过 acquire() 方法去排队。
acquire()
源码如下:
1 | public final void acquire(int arg) { |
tryAcquire 方法尝试获取锁,如果获取锁失败,则把它加入到阻塞队列中,来看 tryAcquire 的源码:
1 | protected final boolean tryAcquire(int acquires) { |
这里公平锁比非公平锁多了一行代码!hasQueuePredecessors()
,它的作用是查看队列中是否有比它等待时间更长的线程,如果没有,尝试一下是否能获取到锁。如果能获取成功,则把当前锁标记为已经被占用。
如果获取失败,则调用addWaiter方法(在acquire()
方法中,见更上面的acquire()
源码)把线程包装成Node对象,同时放入到队列中,但addWaiter方法并不会尝试获取锁,acquireQueued()
方法才会尝试获取锁,如果获取失败,则此节点会被挂起。
acquireQueued()
的源码如下:
1 | // 队列中的线程尝试获取锁,失败则会被挂起 |
这个方法有个很有意思的地方,就是用了for(;;)
这个死循环来尝试获取锁,如果获取失败了,会调用shouldParkAfterFailedAcquire()
来尝试挂起当前线程。shouldParkAfterFailedAcquire()
方法的源码如下:
1 | // 判断是否可以被挂起 |
前驱节点状态为SIGNAL(SIGNAL状态的含义是后继节点处于等待状态,当前节点释放锁后将会唤醒后继节点),线程才会在入队列的时候被挂起。
在上述代码中,先获取前驱节点pred.waitStatus(即ws),然后判断这个节点的状态是否为SIGNAL,如果是SIGNAL,则当前线程可以被挂起并返回true;如果前驱节点状态>0,则表示前驱节点已经被干掉了,它已经失效了,所以要继续往前找更前面的节点,直到找到最近一个正常等待的前驱节点,然后把它当前的前驱节点,然后将其状态设置为SIGNAL.
整个加锁过程到这里就完成了,最后需要注意,没有拿到锁的线程会在队列中被挂起,直到拥有锁的线程释放掉锁之后,才会去唤醒其他的线程去获取锁资源,整个运行流程如下图所示:
再来看一下unlock()的过程:
这个过程相比加锁简单很多。unlock()只涉及到release()和tryRelease()方法。
unlock源码如下:
1 | public void unlock() { |
很简单,我们再深入进去看一下release()方法:
1 | public final boolean release(int arg) { |
可以看出,锁释放的流程为,先调用tryRelease()方法尝试释放锁,如果释放成功,则查看头结点的状态是否为SIGNAL,是则唤醒头结点的下一个关联的线程;如果释放失败,则直接返回false
tryRelease()源码如下:
1 | // 释放当前线程占有的锁 |
在tryRelease()方法中,会先判断当前的线程是不是占用锁的线程,如果不是的话,会抛出异常;如果是的话,会先计算锁的状态值:getState()-releases
是否为0,如果是0,则表示可以正常地释放锁,然后清空独占线程,最后会更新锁的状态并返回执行结果。
JDK1.6之后synchronized关键字底层做了哪些优化?详细介绍一下这些优化?
JDK 1.5 在升级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化上下了很大功夫,比如实现了自适应式自旋锁、锁升级等。
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减 少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐 渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
下面详细讲一下自适应自旋锁和锁升级两个部分。
自适应自旋锁
JDK1.6引入了自适应式自旋锁意味着自旋的时间不再是固定的时间了,比如在同一个锁对象上,如果通过自旋等待成功获取了锁,那么虚拟机就会认为,它下一次很有可能也会成功(通过自旋获取到锁),因此允许自旋等待的时间会相对的比较长,而当某个锁通过自旋很少成功获得过锁,那么以后在获取该锁时,可能会直接忽略掉自旋的过程,以避免浪费 CPU 的资源,这就是自适应自旋锁的功能。
锁升级
锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。
偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如Locking、Unlocking等,直到另一个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。但如果在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。
重量锁:在JDK1.6之前,synchronized是通过操作系统的互斥量(mutexlock)来实现的,这种实现方式需要在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为重量锁。
轻量锁:轻量锁是相对于重量锁而言的,轻量锁是通过比较并交换(CAS,CompareandSwap)来实现的,它对比的是线程和对象的MarkWord(对象头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的MarkWord是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是JDK1.6锁优化的内容。
synchronized关键字底层原理详解
synchronized 关键字底层原理属于 JVM 层面,我们一般不会直接接触其源代码。与之对比的是ReenTrantLock, ReenTrantLock是 JDK 层面实现的(也就 是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看 它是如何实现的。
对于synchronized我们主要看两种情况:
synchronized 同步语句块的情况
这里我们自己做实验,首先写一段简单的代码,用synchronized关键字使得代码块上锁:
1 | public class SynchronizedDemo { |
然后想要看synchronized的工作原理,我们可以看它执行的过程,比如可以看相关字节码文件。我们可以用JDK自带的javap命令查看SynchronizedDemo类的相关字节码信息。先用javac生成其编译后的.class文件,然后执行javap -c -s -v -l SynchronizedDemo.class
结果如下图:
从上图可以看出:
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同 步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图 获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取 锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权.当计数器为0则可以成功获取,获取后将锁计数器设 为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当 前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized修饰方法
代码如下:
1 | public class SynchronizedDemo2 { |
这段代码用javap查看字节码文件:
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来 辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
ReentrantLock的使用和原理
ReentrantLock基于AQS,使用CAS进行加锁,同一时间只能有一个线程成功用CAS加锁。当之前线程执行完之后,会自动唤醒等待队列中队头的线程,然后这个被唤醒的线程会继续用CAS加锁。
需要注意,如果构造ReentrantLock的时候用了无参构造,那么默认是非公平锁。代码如下:
ReentrantLock lock = new ReentrantLock();//非公平锁
即当线程1执行结束之后,如果线程3此时恰好过来请求,那么系统会无视等待队列,直接让线程3使用资源。
如果要定义成公平锁,要传入一个构造参数true,即:
ReentrantLock lock = new ReentrantLock(true);//非公平锁
此时线程3会按照顺序进入等待队列,线程2执行之后线程3才会执行。
如下图所示:
说说Java多线程的互斥锁和同步锁?
通俗解释
首先,所谓互斥锁,有互斥锁,其实就有同步锁。不论是在java语法层面包装成了sycnchronized或者明确的XXXLock,但是底层都是一样的。无非就是哪种写起来方便而已。
所谓锁,就是锁而已,避免多个线程对同一个共享的数据并发修改带来的数据混乱。
锁其实要解决的是这四大问题:
- “谁拿到了锁“这个信息存哪里(可以是当前class,当前instance的markword,还可以是某个具体的Lock的实例)
- 谁能抢到锁的规则(只能一个人抢到 - Mutex;能抢有限多个数量 - Semphore;自己可以反复抢 - 重入锁;读可以反复抢到但是写独占 - 读写锁……)
- 抢不到时怎么办(抢不到玩命抢;抢不到暂时睡着,等一段时间再试/等通知再试;或者二者的结合,先玩命抢几次,还没抢到就睡着)
- 如果锁被释放了还有其他等待锁的怎么办(不管,让等的线程通过超时机制自己抢;按照一定规则通知某一个等待的线程;通知所有线程唤醒他们,让他们一起抢……)
有了这些选择,你就可以按照业务需求组装出你需要的锁。
关于“互斥”和“同步”的概念:
- 互斥就是线程A访问了一组数据,线程BCD就不能同时访问这些数据,直到A停止访问了
- 同步就是ABCD这些线程要约定一个执行的协调顺序,比如D要执行,B和C必须都得做完,而B和C要开始,A必须先得做完。
这是两种典型的并发问题。恰当的使用锁,可以解决同步或者互斥的问题。
你可以说Mutex是专门被设计来解决互斥的;Barrier,Semphore是专门来解决同步的。但是这些都离不开上述对上述4个问题的处理。同时,如果遇到了其他的具体的并发问题,你也可以定制一个锁来满足需要。
更正式的学术解释
所谓互斥,就是不同线程通过竞争进入临界区(共享的数据和硬件资源),为了防止访问冲突,在有限的时间内只允许其中之一独占性的使用共享资源。如不允许同时写
同步关系则是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。如先生产后使用
总的来说,两者的区别就是:
互斥是通过竞争对资源的独占使用,彼此之间不需要知道对方的存在,执行顺序是一个乱序。
同步是协调多个相互关联线程合作完成任务,彼此之间知道对方存在,执行顺序往往是有序的。
Java 内存模型详解(JVM+JMM)(包含类加载器)
java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。可以避免像c++等直接使用物理硬件和操作系统的内存模型在不同操作系统和硬件平台下表现不同,比如有些c/c++程序可能在windows平台运行正常,而在linux平台却运行有问题。
1.说一下JVM的内存模型?(由浅入深)
先简单回答这个问题,要答出两个要点:一个是各部分的功能,另一个是哪些线程共享,哪些线程独占。
JVM内存模型主要指运行时的数据区,包括5个部分。如下图:
- 栈,也叫方法栈,是线程私有的,线程在执行每个方法时都会同时创建一个栈帧,用来存储局部变量表、操作栈、动态链接、方法出口等信息。调用方法时执行入栈,方法返回时执行出栈。
- 本地方法栈与栈类似,也是用来保存线程执行方法时的信息,不同的是,执行 Java 方法使用栈,而执行 native 方法使用本地方法栈。
注:什么是Java的本地方法(native method)?——一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。
“A native method is a Java method whose implementation is provided by non-java code.”在定义一个native method时,并不提供实现体(有些像定义一个java interface),因为其实现体是由非java语言在外面实现的。
- 程序计数器保存着当前线程所执行的字节码位置,每个线程工作时都有一个独立的计数器。程序计数器为执行 Java 方法服务,执行 native 方法时,程序计数器为空。
以上三个部分:栈、本地方法栈、程序计数器,都是线程独占的。
- 堆是JVM管理的内存中最大的一块,堆被所有线程共享,目的是为了存放对象实例,几乎所有的对象实例都在这里分配。当堆内存没有可用的空间时,会抛出OOM异常。根据对象存活的周期不同,JVM把堆内存进行分代管理,由垃圾回收器来进行对象的回收管理。
- 方法区也是各个线程共享的内存区域,又叫非堆区。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,JDK1.7中的永久代和JDK1.8中的Metaspace都是方法区的一种实现。
简单回答之后,可以深入地剖析线程独占和线程共享的每个部分的内容:
线程独占部分
程序计数器(Program Counter Register,即PC)
它是一块较小的内存空间,它可以看做是当前线程所执行的字节码行号指示器(逻辑)。
在虚拟机的概念模型里,字节码解释器工作时,通过改变计数器的值来选取下一条需要执行的字节码指令。包括分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成。
由于JVM的多线程是通过线程之间的来回切换,并且分配处理器执行时间的方式来实现的,所以在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了确保线程在切换后能回到正确的位置,每条线程都需要有一个独立的程序计数器,并且各条线程之间的计数器互不影响,独立存储。我们称这样的内存为”线程私有”的内存,这个计数器的值也和线程之间是一对一的关系。
如果一个线程正在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,那么如果正在执行的是Native方法,则计数器值会为
"Undefined"
此外,由于只是记录行号,程序计数器不会存暴掉,即程序计数器不会存在内存泄漏的问题。
小总结:程序计数器是逻辑计数器,而不是物理计数器。为了线程切换后都能回到正确的执行位置,每个线程都有一个独立的程序计数器,它是线程独立的,并且只为Java方法计数。Native方法对应的程序计数器则是Undefined。使用程序计数器,不用担心会发生内存泄漏的问题。
- 当前线程所执行的字节码行号指示器
- 改变计数器的值来选取下一条需要执行的字节码指令
- 和线程一一对应
- 只对Java方法计数,如果是Native方法则计数器值为Undefined
- 不会发生内存泄漏
Java虚拟机栈(Stack)
Java虚拟机栈也是线程私有的,是Java方法执行的内存模型。每个方法被执行的时候都会创建一个栈帧,结构如下图:
图中的局部变量表和操作数栈有什么区别?
- 局部变量表:包含方法执行过程中的所有变量,包含this引用、所有方法参数,其他局部变量(包括布尔值、Byte、char、long、short、int、float、double等等类型)
- 操作数栈:在执行字节码指令过程中被用到,这种方式类似原生CPU寄存器。大部分JVM字节码把时间花费在操作数栈的操作上,包括入栈、出栈、复制、交换、产生消费变量
栈本身是一个后进先出的数据结构。因此当前执行的方法在栈的顶部,每次方法调用时一个新的栈帧创建,并压入栈顶。当方法正常返回或抛出未捕获的异常时,栈帧就会出栈。除了栈帧的压栈和出栈,栈不能被直接操作。
解读Java可能出现的异常
java.lang.StackOverflowError
递归为什么会引发java.lang.StackOverflowError异常?
递归执行次数过多,栈帧过高。每次调用递归,都会创建一个对应的栈帧,并把建立的栈帧压入虚拟机栈中。
- 如果递归层数过高,不断调用自身,每新调用一次方法,就会生成一个栈帧。
- 它会保存当前方法的栈帧状态
- 栈帧上下文切换的时候会切换到最新的方法栈帧当中
如果递归调用过多,则会产生过多的栈帧,栈帧超过虚拟栈深度限制,就会报错。
解决的方法主要是限制递归的次数,或者可以直接用循环替换递归。
java.lang.OutOfMemoryError
虚拟机栈过多会引发java.lang.OutOfMemoryError异常。当虚拟机栈可以动态扩展时,如果无法申请足够多的内存,就会抛出这个异常。
如果虚拟机栈可以动态扩展,并超出内存,就会报这个错误。
本地方法栈
- 与虚拟机栈类似,主要作用于标注了native的方法
带有native关键字的方法,比如之前的forName0()之类的方法,用的是本地方法栈。
小总结
虚拟机栈是Java虚拟机自动管理的。栈类似一个集合,但是有容量限制,由多个栈帧组合而成。编写代码的时候,每调用一个方法,Java虚拟机就为其分配一块空间,就增加一层栈帧。而当方法调用结束后,对应的栈帧就会被自动释放掉。这就是为什么栈不需要GC,但是堆需要。
线程共享部分
从之前”JDK8内存模型”这张图中可以看到,JVM里线程共享的主要是两个部分:MataSpace和Java堆
元空间(MetaSpace)与永久代(PermGen)的区别
JDK8之后,MetaSpace开始把类的元数据放在本地堆内存中,这段区域在JDK7以及以前,都是属于永久代的。
元空间和永久代都用来存储class的相关信息,包括class对象的方法和filed等。
元空间永久代都是方法区的实现,实现方法不同。方法区只是一种JVM的规范。
Java7之后,把原先位于方法区的字符串常量池移动到了Java堆中,并且在JDK8之后,使用元空间替代了永久代。
这一替代不仅是名字上的替代,两者最大的区别是:元空间使用本地内存,而永久代使用的是JVM的内存。这样设置的一个好处就是解决了内存不足的问题,java.lang.OutOfMemoryError:PemGen space
这个错误将不复存在。因为此时MetaSpace的大小取决于本地内存的大小。本地内存有多大,MetaSpace就有多大。当然,实际运行的时候不可能放任MetaSpace的壮大,JVM在运行的时候会根据需要动态地设置其大小。
MetaSpace相比PermGen的优势
- 字符串常量池存在永久代中,容易出现性能问题和内存溢出
- 永久代类和方法的信息大小难以确定,给永久代的大小指定带来困难
- 永久代会为GC带来不必要的复杂性,而且回收效率可能较低
- 用元空间方便HotSpot与其他JVM如Jrockit的集成
重点记住:元空间和永久代的主要区别,是前者内存空间主要使用的是本机内存。MetaSpace没有了字符串常量池,它在JDK7中已经被移动到了堆中。MetaSpace其他存储的东西,包括类文件,在JVM运行时候的数据结构,以及class相关的内容,如method,filed,大体上都与永久代一样,只是划分上更加合理。比如类元素的生命周期与类加载器一致,每个类加载器(classLoader)都会分配一个单独的存储空间。
Java堆(Heap)
对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有内存共享的一块内存区域。在虚拟机启动时创建此内存区域的唯一目的,就是存放对象实例,几乎所有的对象实例都在这里分配内存。
以一个32位处理器的Java内存布局为例:
可以看到,Java堆会占用非常大的一块内存。
此外,Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆。
如果从内存回收的角度看,由于现在的收集器基本都采用分代收集的算法,所以Java堆中还可以细分为新生代和老年代。
可以参见下图:
JVM的运行模式(server和client)
JVM有两种运行模式:Server和Client。
这两种运行模式的区别在于:Server启动较慢,Client启动较快。但是启动后运行进入稳定期之后,Server模式的程序运行速度比Client更快。
因为Server模式启动的是重量级的虚拟机,对程序采用了更多优化,对比之下Client模式启用的是轻量级的虚拟机。
如果想要查看当前Java是Server模式还是Client模式,可以直接用java -version
查看即可.
2.JMM如何保证原子性、可见性、有序性?
首先应当搞清楚,JMM和JVM内存模型是完全不同的两个概念。JVM内存模型主要针对运行时数据区而言,而JMM是指Java程序中变量的访问规则,两者是完全不同的两个概念。
JMM如下图所示:
所有共享变量都存储在主内存中,共享。每个线程都有自己的工作内存,而工作内存中保存的是主内存中变量的副本,线程对变量的读写等操作必须在自己的工作内存中进行,而不能直接读写主内存中的变量。
综合1和2两个部分,我们要注意,在回答”说一下JVM的内存模型?”这个问题的时候,要和面试官确认是希望回答JVM的内存模型,还是Java对内存数据访问的模型,不要答跑偏。
在多线程进行数据交互时,例如线程A给一个共享变量赋值后,由线程B来读取这个值,A修改完变量是修改在自己的工作区内存中,B是不可见的,只有从A的工作区写回主内存,B再从主内存读取自己的工作区才能进行进一步的操作。由于指令重排序的存在,这个写—读的顺序有可能被打乱。因此 JMM 需要提供原子性、可见性、有序性的保证。
下面来详细分析一下如何保证这三个特性的。
首先,还是来看一张图:
从图中已经可以看到,为了保证原子性、可见性、有序性,我们有不同的方法。
原子性:
原子性即线程T1在对变量a操作的时候,线程T2不能对变量a有任何操作,即T2必须等到T1的所有操作结束之后才能对变量a操作。
JMM保证对除long和double外的基础数据类型的读写操作是原子性的。另外关键字synchronized也可以提供原子性保证。synchronized的原子性是通过Java的两个高级的字节码指令 monitorenter 和 monitorexit 来保证的。
可见性:
可见性即T1线程对一个变量修改之后,T2线程能够马上看到自己操作的变量值被修改了。
JMM可见性的保证,一个是通过synchronized,另外一个就是volatile。volatile强制变量的赋值会同步刷新回主内存,强制变量的读取会从主内存重新加载,保证不同的线程总是能够看到该变量的最新值。
有序性:
有序性即要保证不出现指令重排(JVM优化的时候会有指令重排的情况)。举个例子,T2需要变量flag为true才能执行,而flag只有在T1中才会被置为true。这里如果发生指令重排,T1还没执行完的时候T2就感知到了flag为true,那么程序执行逻辑就出错了。
对有序性的保证,主要通过 volatile 和一系列 happens-before 原则。volatile 的另一个作用就是阻止指令重排序,这样就可以保证变量读写的有序性。
happens-before 原则包括一系列规则。
编译器、指令器可以为了优化性能而对代码重排序,乱排,但是要遵守这些happens-before原则,只要符合happens-before的原则,那么就不能胡乱重排,如果不符合这些规则的话,那就可以自己排序。一些happens-before原则为:
程序顺序原则,即一个线程内必须保证语义串行性。通俗来说,就是一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
锁规则,即对同一个锁的解锁一定发生在再次加锁之前。通俗来说,就是一个unLock操作先行发生于后面对同一个锁的lock操作。比如说在代码里;先有lock.unlock(),再有lock.lock(),那么这个前unlock()后lock.lock()的顺序不能重排。
volatile变量规则:即对一个volatile变量的写操作要在读操作前面。即对这个volatile变量必须保证先写,再读。
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作,thread.start(),thread.interrupt()。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。
对于有序性的happens-before,面试的时候如果问到,不是要求你把上面8条全部背出来!
你可以说个大概,happens-before规则制定了在一些特殊情况下,不允许编译器、指令器对你写的代码进行指令重排,必须保证你的代码的有序性,但是如果没满足上面的规则,那么就可能会出现指令重排,就这个意思。
3.哪些情况下类会进行初始化?
主要有六种情况下类会进行初始化:
- 创建类的实例
- 访问某个类或接口的静态变量,或对该静态变量赋值。
- 调用类的静态方法。
- 初始化一个类的子类时(初始化子类,父类必须先初始化)。
- JVM启动时被标为启动类的类。
- 使用反射进行方法调用时会生成该类,比如class.forName(“类名”)。
4.类加载过程是怎样的?JVM是如何运行起来的?我们的对象是如何分配的?
类的加载指将编译好的 Class 类文件中的字节码读入内存中,将其放在方法区内并创建对应的 Class 对象。类的加载分为加载、链接、初始化,如下图:
其中链接又包括验证、准备、解析三步。展开之后如下图所示。
整个加载过程描述如下:
- 首先进行加载操作,首先查找到这个类的字节码文件,然后使用这个字节码文件创建一个class对象,这样就实现了从文件到内存的过程。
- 验证是对类文件内容的验证。主要是为了虚拟机自身安全考虑,保证当前class文件符合要求。验证主要包括四种:文件格式、元数据、字节码、符号引用的验证。
- 准备阶段是进行内存分配。为类中用static修饰的变量分配内存并设置初始值。需要注意这里只会赋值0或者null,程序中指定赋的初始值是在初始化阶段赋值的,不是在这一阶段赋值的。此外,这里也不包含用final修饰的静态变量,因为final的变量会在编译的时候分配。
- 解析主要是解析字段、接口、方法。主要是将常量池中的符号引用替换为直接引用的过程。(啥是直接引用?就是直接指向目标的指针、相对偏移量等)
- 初始化,主要完成静态代码块执行与静态变量的赋值(注意,用static修饰的变量在这一步才进行赋值)。这是类加载的最后阶段,若被加载的类的父类没有初始化,则先对父类进行初始化。——注意,只有对类主动使用时才会进行初始化。初始化的触发条件包括在创建类的实例时、访问类的静态方法或者静态变量时、Class.forName() 反射类时、或者某个子类被初始化时。
需要注意一点,由Java虚拟机自带的三种类加载器(BootStrap启动类加载器、ExtClassLoader扩展类加载器、AppClassLoader应用加载器(也称系统加载器))加载的类在虚拟机的整个生命周期中是不会被卸载的,只有用户自定义的类加载器所加载的类才可以被卸载。
实际上我们平时写的代码一定会有很多线程去执行,比如程序有一个类里面包含了一个main方法,你去执行这个main方法,此时会启动一个jvm进程,程序会默认有一个main线程,这个main线程就负责执行这个main方法的代码,进而创建各种对象,然后执行业务逻辑等等。
对于服务器也是类似。比如基于JDK的Tomcat,类会加载到JVM中;Spring 容器会把所有管理的类实例化成Bean,然后有线程使用Bean,等等。
栈里面放局部变量,先入后出,每次有新的对象被访问时都会新定义一个栈帧然后入栈、堆中放的是对象和对象和实例。方法执行完毕后,会把栈里面的栈帧弹出,并且对堆进行垃圾回收。
5.Java类加载器有几种?关系是怎样的?
上面标红的内容已经提到了, 类加载器一共有四种,其中三种是JVM自带的:
- BootStrap启动类加载器
- ExtClassLoader扩展类加载器
- AppClassLoader应用加载器(也称系统加载器)
还有一种是使用者可以自定义的类加载器:Custom ClassLoader(自定义类加载器)。
这四种类加载器有上下层级关系,而且JVM自带的类加载器有默认的加载目录。如下图所示:
启动类加载器会加载JAVA_HOME
中lib目录下的类,扩展加载器会负责加载ext目录下的类。而应用加载起会加载classpath指定目录下的类,自定义加载器是最下层的子类加载器,即Custom ClassLoader。
6.双亲委派机制的加载流程是怎样的,有什么好处?
双亲委派的加载流程就如下图所示(再展现一次):
这四个类加载器详解:
- BootStrap ClassLoader:引导类加载器。最顶层的类加载器,负责加载JDK中的核心类库,比如rt.jar, resources.jar, charsets.jar等。其位置都在”JAVA_HOME/jre/lib”下
- ExtClassLoader:扩展类加载器。主要负责加载Java的扩展类库,默认加载”JAVA_HOME/jre/lib/ext/“目录下的所有jar包或者由java.ext.dirs系统属性指定的jar包。放入这个目录下的jar包对AppClassLoader加载器都是可见的。(因为ExtClassLoader是AppClassLoader的父加载器,并且Java类的类加载器采用双亲委派机制)。
- AppClassLoader:应用类加载器,又称为系统类加载器。负责在JVM启动时,加载来自命令中的classpath或者java.class.path系统属性或者CLASSPATH操作系统属性所指定的jar类包和类路径。
其中在上面的是父类加载器,在下面的是子类加载器。Java在整个类加载过程都是通过双亲委派机制来实现的,即一个类加载器在加载类时,先把这个请求委托给自己的父类加载器去执行,如果父类加载器还存在父类加载器,就继续向上委托,直到顶层的启动类加载器,如上图中黑色向上的箭头。如果父类加载器能够完成类加载,就成功返回;如果父类加载器无法完成加载,则子类加载器才会尝试自己去加载,即图中的蓝色向下的箭头。
双亲委派机制的好处主要是两个:
- 避免类的重复加载,节省资源
- 避免了Java核心API被篡改,因为一个类只会被加载一次。如果用户重写了Object类,那么不能用用户写的。
一个经常被补充问到的问题:如何保证双亲委派的类是同一个?
方法是根据全类名和类加载器是否都一样来判断。
7.双亲委派机制被破坏的情况
实际上,双亲委派这种加载流程只是Java设计者推荐给开发者的类加载器的实现方式。虽然Java世界中大部分类加载器都遵循这个规则,但当然有而且允许有例外,这就是破坏了双亲委派的机制了。
简单来说,双亲委派一般有两种破坏方法:
- 自定义类加载器,重写loadClass方法;
- 使用线程上下文类加载器;
JDK在历史上对双亲委派机制有三次破坏:
- 第一次破坏
由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法唯一逻辑就是去调用自己的loadClass()。
- 第二次破坏
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。
如果基础类又要调用回用户的代码,那该么办?
一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,
它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
- 第三次破坏
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
1)将java.*开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类加载器失败。
实际上,双亲委派机制”被破坏”并不是贬义词,只要有足够的意义和理由,其实突破原有的双亲委派机制也是一种创新。比如OSGi对类加载器的使用时很值得学习的。弄懂了OSGi的实现,就可以算是掌握了类加载器的精髓。
8.JDK8之后对内存分代做了什么改进?为什么用Metaspace替换掉PermGen?Metaspace保存在哪里?
JDK8之前,永久代里放了一些常量池+类信息。java 8以后进行了内存分代的改进,变成了:常量池放到堆里面,类信息放到了 metaspace(元区域)。
实际上,JDK1.8版本中对方法区进行了调整,使用Metaspace替换掉了PermGen的永久代。Metaspace与PermGen之间最大的区别在于:Metaspace并不在虚拟机中,而是使用本地内存。替换的目的主要有两个:
- 可以提升对元数据的管理同时提升GC效率
- 方便后序HotSpot与JRockit合并。
9.编译器会对指令做哪些优化?(简单描述编译器的指令重排)
为了性能考虑,编译器和CPU可能会在执行过程中对指令重新排序。
简而言之,就是程序在CPU执行的时候会细分为不同的指令,这些指令互相之间可能有依存关系,即某个操作需要等待另一个变量的计算结果,如果都是类似串行的执行,那么CPU的多核用不上,浪费了。我们可以通过指令重排让计算结果不相关的指令交替执行,这样哪怕是有的语句要等待别的语句的计算结果,也可以让整体执行效率比较高。
当然,这样的重排在多线程的时候有出错风险,所以有类似volatile的指令来禁止或者限制指令重排。
具体细节可以参考这篇文章
10.对象引用有哪几种方式,有什么特点?
重点介绍Java的四种引用:强、弱、软、虚,以及在GC中的处理方式。
强引用(Strong Reference)
- 最普遍的引用:Object obj = new Object(),这里obj就是强引用
- 抛出OutOfMemoryError终止程序也不会回收具有强引用的对象
- 通过将对象设置为null来弱化引用,使其被回收
弱引用(Weak Reference)
- 非必须的对象,比软引用更弱一些
- 生命周期更短,在GC时会被回收——无论当前内存是否紧缺,GC都会回收被弱引用关联的对象
- 被回收的概率也不大,因为GC线程优先级比较低
- 弱引用适用于偶尔使用且不影响垃圾收集的对象
弱引用案例:
弱引用同样可以配合引用队列去使用。
软引用(Soft Reference)
- 对象处在有用但非必须的状态
- 只有当内存空间不足时,GC会回收该引用的对象的内存
- 可以用来实现高速缓存——这样我们就可以避免OutOfMemory的问题。因为软引用的内存会在内存不足的情况下回收。
强引用和软引用例子如下图:
虚引用(Phantom Reference)
“虚无缥缈”,其生命周期比较不固定
- 不会决定对象的生命周期
- 任何时候都可能被垃圾收集器回收
- 跟踪对象被垃圾收集器回收的活动,起哨兵作用
- 比较特殊,必须和引用队列ReferenceQueue联合使用
GC在回收一个对象时,若发现这个对象有虚引用,那么回收前会先将这个引用加入到与之关联的引用队列当中。
四种引用之间的关系
引用类结构层次
引用队列(ReferenceQueue)
引用队列名义上是一个队列,但其内部没有实际存储结构。
- 无实际存储结构,存储逻辑依赖于内部节点之间的关系来表达的——类似链表,节点是Reference本身,它自己只存储链表的头结点,而后面的节点都通过Reference指向下一个的next来保持。
- 存储关联的且被GC的软引用,弱引用以及虚引用
11.使用过哪些JVM调优工具,主要分析哪些内容?举一个JVM抗住并发的实例?
一个JVM调优实例
如果我们要设置Spring Boot和Tomcat部署的系统的JVM参数,比较方便:Spring Boot其实就是启动的时候可以加上JVM参数,Tomcat就是在bin目录下的catalina.sh中可以加入JVM参数。
实际上,如果只是普通的系统,比如普通的电商系统,每日上亿请求量,按照每个用户平均每日访问20次来算,那么上亿的请求量大概需要500万日活用户。而按照10%的交易转换率来算,差不多每天只有50万用户下单,就算这50万单集中在高峰期的四个小时,平均下来每秒也就几十个订单。几十个订单的压力下,根本不需要对JVM过多关注,基本就是每秒占用一些新生代内存,隔很久新生代才会满,然后用Minor GC之后内存又空了,没啥压力。
但是很需要考虑的就是一些特殊的比如电商大促的场景,双11场景,就很不同了。大家都在凌晨时分等待剁手,可能在短短10分钟内,就有50万订单,折算下来每秒1000下单请求!
这样大的并发,如何能抗住呢?需要几台机器呢?我们可以用内存模型分析,如果我们把这每秒1000的请求用三台机器去抗,那么每台机器每秒抗300并发量即可。假设订单部署在的系统是最普通的标配4核8G的机器上,那么理论上,可以抗住,但是需要对JVM有限的内存资源进行合理的分配和优化,包括对垃圾回收进行合理的优化,让JVM的GC次数尽可能最少,而且尽量避免Full GC,这样可以尽可能减少JVM的GC对高峰期的系统更新的影响。
具体来说,可以按照每秒300个下单量来估算(这样的值和生产其实也比较接近了),因为下单操作涉及很多接口调用,基本上每秒处理100300个下单请求差不多。我们每个订单按照1kb来估算,300个订单会有300kb内存开销,然后算上订单对象连带的订单条目对象、库存、促销、优惠券等等一系列的其他业务对象,一般需要对单个对象开销放大10倍20倍。
此外,除了下单之外,这个订单系统还会有很多订单相关的其他操作,比如订单查询之类的,所以连带算起来,可以往大了估算,再扩大10倍的量。
那么每秒钟会有大概300kb * 20 * 10 = 60mb的内存开销。但是一秒过后,可以认为这60mb的对象就是垃圾了,因为300个订单处理完了,所有相关对象都失去了引用,可以回收的状态。当然,这60mb的对象都会保存在新生代区域。
那么内存应该如何分配呢?
假设我们有4核8G的机器,那么给JVM的内存一般会到4G,剩下几个G会留点空余给操作系统之类的来使用,不要想着把机器内存一下子都耗尽,其中堆内存我们可以给3G,新生代我们可以给到1.5G,老年代也是1.5G。然后每个线程的Java虚拟机栈有1M,那么JVM里如果有几百个线程大概会有几百M,然后再给永久代256M内存,基本上这4G内存就差不多了。
在JDK1.6之后,“-XX:HandlePromotionFailure”参数就被废弃了,所以现在一般都不会在生产环境里设置这个参数了。在JDK 1.6以后,只要判断“老年代可用空间”> “新生代对象总和”,或者“老年代可用空间”> “历次Minor GC升入老年代对象的平均大小”,两个条件满足一个,就可以直接提前进行Minor GC,不需要提前触发Full GC了。
注:
XX:HandlePromotionFailure
用于空间分配担保。在发生Minor GC(Yong GC)之前,JVM会计算Survivor区移至老年区的对象的平均大小,虚拟机会检查老年代最大可用的连续空间是否大于需要转移的对象大小。
如果大于,则此次Minor GC(Yong GC)是安全的。
如果小于,jdk1.6之前:则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
如果大于,则尝试进行一次Minor GC(Yong GC),但这次Minor GC(Yong GC)依然是有风险的,失败后会重新发起一次Major GC(Full GC);
如果小于或者HandlePromotionFailure=false,则改为直接进行一次Major GC(Full GC)。
但是在jdk1.6 update 24之后-XX:-HandlePromotionFailure 不起作用了,只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行MonitorGC,否则FullGC
但是这里会存在一个问题,就是每秒处理300个订单,都会占据新生代60MB的内存空间。但是1秒过后这60MB对象都变成了垃圾,那么新生代1.5GB的内存空间大概需要25秒就会被占满,然后进行回收,第一次肯定能正常Minor GC,然后一下子回收掉了99%的新生代对象,因为大部分订单都处理完了,除了最近一秒的订单请求还在处理,这个剩余的订单大小可能就100MB左右。
但是这里问题来了,如果“-XX:SurvivorRatio”参数默认值为8(此参数为Eden区和S1与S2区的比,默认为8),那么此时新生代里Eden区大概占据了1.2GB内存,每个Survivor区是150MB的内存。即Eden满了1.2GB就要进行Minor GC,因此大概只需要20秒,就会把Eden区塞满,然后就必须进行Minor GC了。
就像上面所说,GC后只剩上一秒的订单,大小就100MB左右。这100MB会放入到S1区中。然后再运行20秒,Eden又满了,再次垃圾回收Eden和S1中的对象,这次可能存活的对象还是100MB左右,这些对象会进入S2区。
此时JVM的参数会大致如下:
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
1 | 参数解释:常见参数种类(配置内存) |
优化的时候到了。
首先,新生代垃圾回收器需要优化。我们的Survivor(S区)区空间够不够用?
按照上述逻辑,首先每次新生代垃圾回收在100MB左右,有可能会突破150MB,那么岂不是经常会出现Minor GC过后的对象无法放入Survivor中?然后岂不是频繁会让对象进入老年代?(年轻代垃圾回收器放不下时就会放入老年代)
所以按照上面的参数设置,S区明显不够用。
所以这里的建议是:调整新生代和老年代的大小,因为这种普通业务系统,明显大部分对象都是短生存周期的,根本不应该频繁进入老年代,也没必要给老年代维持过大的内存空间,首先得先让对象尽量留在新生代里。
具体做法是增加新生代的容量,从1.25GB增加到1.5GB,这样Eden为1.6GB,每个Survivor为200MB。
此时,S区域变大,大大降低了新生代GC过后存活对象在S里放不下的问题,或者是同龄对象超过S区 50%的问题,从而大大降低新生代对象进入老年代的概率。
此时JVM的参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
其实对任何系统,首先类似上文的内存使用模型预估以及合理的分配内存,尽量让每次Minor GC后的对象都留在Survivor里,不要进入老年代,这是你首先要进行优化的一个地方。
其次,设置参数,新生对象经过多少次垃圾回收后进入老年代?
默认是15,即一个对象经过15次Minor GC后还在S区,就将其转移到老年代。但是这里实际上可以考虑把15调小,比如调到5。
为什么呢?这个必须结合系统的运行模型来说。来说,如果躲过15次GC都几分钟了,一个对象几分钟都不能被回收,说明肯定是系统里类似用@Service、@Controller之类的注解标注的那种需要长期存活的核心业务逻辑组件。
所以我们这里应该减小-XX:MaxTenuringThreshold
的参数值,让对象尽快离开新生代S区。
比如调小到5,则此时JVM参数为:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5
接下来,可以设置多大的对象可以进入老年代。
一般来说,设置1MB够用了,因为一般很少有超过1MB的大对象。如果有,可能是你提前分配了一个大数组、大List之类的东西来存放缓存的数据。
设置之后的参数为:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 XX:PretenureSizeThreshold=1M
最后,如果不用G1这种比较新的垃圾收集器,我们需要设置新生代和老年代的垃圾回收器。如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
ParNew垃圾回收器的核心参数,其实就是配套的新生代内存大小、Eden和Survivor的比例,只要你设置合理,避免Minor GC后对象放不下Survivor进入老年代,或者是动态年龄判定之后进入老年代,给新生代里的Survivor充足的空间,那么Minor GC一般就没什么问题。
JVM调优工具
有关JVM调优工具可以参考这篇博客:jvm系列(七):jvm调优-工具篇
可以了解一下 Java 自带的几种工具的功能,例如 JMC 中的飞行记录器,堆分析工具 MAT,线程分析工具 jstack 和获取堆信息的 jmap 等。
12.什么是垃圾回收的stop the world和 Safepoint?
Stop-the-World
- JVM由于要执行GC而停止了应用程序的执行
- 会在任何一种GC算法中发生
- 多数GC优化通过减少Stop-the-World发生的时间来提高程序性能,从而让系统有高吞吐,低停顿的特点
如果执行的垃圾回收比较慢,比如要回收100mb,那么可能stop the world需要100ms,这段期间不能处理任何请求,要尽可能地让垃圾回收和工作线程的运行并发执行。
Safepoint
JVM垃圾回收就好比是保洁阿姨在打扫卫生,如果一边打扫一遍有人扔垃圾,那很难能打扫完。怎么办呢?可以在开始打扫之前和所有人说好:”我要开始打扫了!你们不准扔垃圾了!”,这样就可以了。
安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。所以SafePoint的选定不能太长以至于让GC等待时间太长,也不能过于频繁导致过分增大运行时的负荷。由于JVM系统运行期间的复杂性,不可能做到随时暂停,因此引入了安全点的概念。
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
- 分析过程中对象引用关系不会发生变化的点——这是程序运行过程中的一个特殊点的,在这个点所有线程都被冻结了,不能出现分析过程中对象引用关系还在不断变化的情况。类似函数的可导,我们分析的结果需要在某个节点具备确定性,这个节点就叫做安全点。
- 产生Safepoint的地方一般是:方法调用;循环跳转;异常跳转等
- 安全点数量得适中——安全点选择不能太多也不能太少
两种安全点的解决方案:
- 抢先式中断(Preemptive Suspension)
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。
- 主动式中断(Voluntary Suspension)
主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
13.简单描述一下GC的分代回收
首先我们可以比较容易地理解为什么需要GC。我们的资源不可能是无限的,而且资源很多时候也是昂贵的,2核4G的机器,堆内存也就2GB左右,4核8G的机器,堆内存可能也就4G左右,栈内存也需要空间,metaspace区域放类信息也需要空间。所以,在jvm里必然是有一个内存分代模型,年轻代和老年代,年轻代中是经常要被清理的,而常用的放到老年代,清理的频次更低一些。
实际上,Java堆中可以分为年轻代(新生代)和老年代,如下图所示:
比如内存一共是4G,可以给年轻代一共2GB内存,给老年代是2GB内存。对于年轻代,默认情况下Eden和2个s区(s1和s2区)的比例:8:1:1,即此时Eden是1.6GB,S是0.2GB。
如果说eden区域满了,此时必然触发垃圾回收,young gc,ygc,如何判断某个对象是否是可回收的对象呢?——没有其他对象引用的对象就是垃圾对象。有关如何定义没有被其他对象引用,可以有两种标记算法:引用计数算法和可达性分析算法。下面介绍。
需要注意,年轻代有两种对象不能回收:
- 一个方法正在执行,这个正在执行的方法中的局部变量引用了某个对象,那被引用的这个对象不能被回收。
- 一个类是存活着的,这个类里面的静态变量引用了某个对象,那这个对象也不能回收。
除此之外,所有没被引用的对象,都可以是GC的目标对象。
Java的堆内存被分类管理的,这主要是为了方便垃圾回收,分代管理这样的做法基于两个原因:
- 大部分对象很快就不再被系统需要和使用
- 有一部分对象不会立即无用,但也不会持续很长时间
14.介绍一下常用的两种对象标记算法?
怎样的对象会被判定为垃圾?
- 没有被其他对象引用
此时这个对象占据的内存会被释放,此对象也会被销毁。
用什么方法判定对象不被引用了呢?
- 引用计数算法
- 可达性分析算法
引用计数算法
通过判断对象的引用数量来决定对象是否可以被回收。
具体执行方法:
- 每个对象实例都有一个引用计数器,被引用则+1,完成引用则-1
- 任何引用计数为0的对象实例可以被当做垃圾收集
引用计数算法的优劣:
- 优点:执行效率高,程序执行受影响较小。因为我们只需要过滤掉引用计数为0的对象,然后将其回收即可,可以交织在程序运行中。由于垃圾回收的过程中可以做到几乎不打断程序的执行,所以这种方法适用于程序需要不被长时间打断的实时环境。
- 缺点:无法检测出循环引用的情况,导致内存泄漏。这个缺点是很致命的,如果存在父对象与子对象互相引用的情况,那么它们的引用计数永远不可能为零,那么永远都不会被检测到为0,永远不会被释放。
由于这种比较致命的缺点,主流JDK没有使用引用计数算法进行垃圾判定,而是用了下面的可达性分析算法。
可达性分析算法
通过判断对象的引用链是否可达来决定对象是否可以被回收。
这种方法从图论中引入。程序把所有的引用关系看作是一张图,通过一系列的名为GC Root的对象作为起始点,从这些节点开始向下搜索,搜索经过的路径会被称为”引用链”,即”reference chain”,当某个对象到其他图中的节点都不能相连的时候,也就是说从这个对象到其他部分的GC Root是不可达的,那么就判定这个对象为垃圾。
如图:
蓝色为存活对象,即可达对象,灰色的部分不可达了,为垃圾对象。
什么对象可以作为GC Root的对象呢?
- 虚拟机栈中引用的对象(栈帧中的本地变量表)。比如在方法中new了一个Object,并赋值给了一个局部变量,那么在该局部变量没有被销毁之前,new出来的对象就会是GC Root。
- 方法区中的常量引用的对象。比如在类中定义了一个常量,而该常量保存的是某个对象的地址,那么被保存的对象也会成为GC的根对象。
- 方法区中的类静态属性引用的对象。这个和上面常量的情况如出一辙。
- 本地方法栈中JNI(Native方法)的引用对象
- 活跃线程的引用对象
15.介绍一下垃圾回收算法?
判断了哪些对象是垃圾只是第一步,我们还需要解决一个很重要的问题:如何处理这些垃圾?或者说,如果回收这些垃圾?
垃圾回收算法有以下这几种:
标记-清除算法(Mark and Sweep)
- 标记:从根集合进行扫描,对存活的对象进行标记
- 清除:对堆内存从头到尾进行线性遍历,回收不可达对象内存
如图:
如上图所示,经过了Mark阶段到达Sweep阶段的时候,所有不可达的对象都会被当做垃圾回收掉。
但是这种方法会存在一些问题,在标记-清除之后,可能会产生大量不连续的碎片,空间碎片多,可能导致之后开辟大对象空间的时候出现内存不够用的情况。
复制算法(Copying)
复制算法将可用的内存按照容量和一定比例划分为两块或多块,并选择其中一块两块作为对象面,其他的作为空闲面。
- 分为对象面和空闲面
- 对象在对象面上创建
- 存活的对象被从对象面复制到空闲面。当被定义为对象面的块的内存用完了,就将还存活着的对象复制到其中一块空闲面上
- 将对象面所有对象清除
这种算法适用于对象存活率低的场景,比如年轻代。这样每次都对内存块进行回收,这样就解决了内存碎片的问题。
推倒重建的过程只需要移动堆顶指针,按顺序分配内容即可。
优势:
- 解决碎片化问题
- 顺序分配内存,简单高效
- 适用于对象存活率低的场景(现在很多虚拟机都采用这种方法回收年轻代,因为年轻代每次都只存活10%左右,用复制算法效果不错)
但是在老年代不能轻易选用这种算法,因为可能出现存活率特别高的情况。
标记-整理算法(Compacting)
这种算法比较适合用于老年代的对象回收。它使用类似”标记-清除”算法的方式进行对象的标记,但是在清除的时候不同。
- 标记:从根集合进行扫描,对存活的对象进行标记
- 清除:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收
“标记-整理”算法是在”标记-清除”的基础上又进行了对象的移动,因此成本更高,但是能够解决内存碎片的问题。
也可以参考这张图:
执行这个算法的时候会把存活的对象压缩到一端,然后将所有可回收的对象清除掉。
这样做的好处:
- 避免内存的不连续性
- 不用设置两块内存互换
- 适用于存活率高的场景(比如涉及分代收集算法中老年代的回收)
(GC,最主流)分代收集算法(Generational Collector)
这是一种比较主流的垃圾回收算法。
可以理解是一套”组合拳”
- 垃圾回收算法的组合拳
- 按照对象生命周期的不同划分区域以采用不同的垃圾回收算法
- 目的:提高JVM的回收效率
前面已经提过,JDK8之前,比如JDK6和JDK7,里面有年轻代、老年代和永久代,如下图:
但是JDK8之后(包括JDK8)就去掉了永久代:
可以看到,JDK6、JDK7和JDK8中都有年轻代和老年代。其中年轻代的对象存活率低,采用复制算法。而老年代存活率高,一般使用”标记-清除算法”或者”标记-整理算法”。
GC的分类
分代收集的GC分为两种:
- Minor GC。发生在年轻代中的垃圾收集工作,采用复制算法。
- Full GC。与老年代的垃圾回收相关。
年轻代是所有Java对象出生的地方,即Java对象申请的内存和存放对象,都是在年轻代进行的。
实际上,Java大部分对象都不会长久存活,”朝生夕灭”。新生代是GC发生的频繁区域。
老年代的回收一般伴随着年轻代的垃圾收集,因此第二种垃圾回收方式被命名为”Full GC”
年轻代:尽可能快速地收集掉那些生命周期短的对象
- Eden区
- 两个Survivor区
年轻代用到的算法基本都是复制算法。
年轻代主要用来存放新建的对象,分为Eden和两个Survivor区(又被称为S1和S2或者from区和to区)。大部分对象在Eden区生成,当Eden区满时,还存活的对象会在两个Survivor区交替保存,达到一定存活时间(比如15)之后会晋升到老年代。
对象刚被创建出来的时候,其内存空间首先被分配在Eden区。如果Eden区放不下新创建的对象的话,对象也有可能被直接放在Survivor甚至是老年代中。
而两个Survivor则分别被定义在from区和to区,并且哪个是from区,哪个是to区,也不是固定的,会随着垃圾回收的进行而相互转换。
年轻代垃圾回收的过程演示
通过一个实例演示年轻代的垃圾回收过程:
演示过程暂时忽略Eden区和Survivor区的大小比例,并且假设每个对象的大小都是一样的。Eden区最多能保存四个对象,Survivor区最多能保存三个对象。
一开始,如果对象在Eden出生,并且Eden被挤满,如下图:
此时会触发一次Minor GC。此时如果对象还存活(绿色的为存活对象),它就会被复制到一个Survivor区里面,假设是复制到了S0里面,此时我们称S0为from区。复制之后会增加1个年龄。比如图中复制过去之后年龄为1.
然后清理所有使用过的Eden区域,如下图:
之后会清空Eden
然后过了一段时间,发现Eden区又被填满了,如图:
此时又会触发一次Minor GC,然后将Eden和S0里面的存活的对象都拷贝到S1里面,同时会把存活的对象的年龄都加1。
此时S1从to区变成了from区,而S0从from区变成了to区。
拷贝完成后,Eden和S0都会被清空,以此完成了第二次Minor GC。
之后我们假设Eden区又满了:
此时会出发第三次Minor GC,操作行为也和之前一样,年龄加1。同时S1里面如果有一个对象没有被用到,那么也要把它清除。
每次拷贝,存活对象的年龄都要加1.
拷贝完成后,S1和Eden又会被再次清空:
周而复始。对象在Survivor区每熬过一次Minor GC,其年龄就会被加1,当对象的年龄达到某个值之后(默认是15岁),这些对象会成为老年代。
NOTE:这个默认年龄可以通过-XX:MaxTenuringThreshold
调整
但这也不是一定的,如果存储的对象过大,Eden区和Survivor区都存不下,可能会需要用到老年代的空间协助存储。
年轻代对象如何晋升到老年代
在分代算法当中,年轻代对象如何晋升到老年代?简单来说有3种场景:
- 经历一定Minor GC次数(比如是15次)依然存活的对象
- Survivor区中存放不下的对象
- 新生成的大对象(可以用:
-XX:+PretenuerSizeThreshold
来控制大对象的大小,只要大于这个大小,对象生成之后直接放入老年代)
常用的调优参数
介绍几个常用的用来做性能调优的参数。
- -XX:SurvivorRatio:Eden和Survivor的比值,默认8:1
- -XX:NewRatio:老年代和年轻代内存大小的比例(比如若值为2,则老年代是年轻代大小的两倍,即young generation占据内存的三分之一)。
- -XX:MaxTenuringThreshold:对象从年轻代晋升到老生代经历过GC次数的最大阈值
新生代和老年代的总内存大小是通过”-Xms”和”-Xmx”来决定的。
老年代:存放生命周期较长的对象
首先应该明确,老年代不适合用类似年轻代的复制算法,因为老年代的对象很多都是被长期引用的,spring容器管理各种的bean。老年代中长期存活的对象是比较多的,可能甚至有几百MB,这么大的对象复制来复制去,效率很低。而且老年代中垃圾没有那么多。
回顾这副图:
可以看到,老年代占的内存比新生代大,而且大致的比例为2:1
老年代的对象存活率较高,而且没有额外空间做担保,所以老年代主要用的算法为:
- 标记-清理算法(致命缺点是会产生内存碎片)
- 标记-整理算法(把老年代里的存活对象标记出来,移动到一起,存活对象压缩到一片内存空间里去,剩余的空间都是垃圾对象,把它们全部清理掉,剩余的都是连续的可用的内存空间,解决了内存碎片的问题)
其中,老年代用到的更多的是标记-整理算法
之前已经详细介绍过这两种算法,这里就不再介绍了。
16.常见的垃圾回收器有哪些?
垃圾收集器之间的联系
垃圾收集器不存在哪个好那个坏的问题,而是涉及到适合哪个具体的JVM。不同的厂商,不同版本的JVM,提供的选择也不同,这也是HotSpot实现这么多收集器的原因。
一些常见的垃圾收集器、它们之间的关系和它们的适用范围,如图所示:
如果两个收集器之间有连线,说明它们可以搭配使用。
我们只需要大致熟悉每一个垃圾收集器的作用即可。
下面分别介绍:
年轻代收集器
Serial收集器
可以在程序启动的时候通过-XX:+UseSerialGC
设置使用此收集器。使用复制算法。
在JDK1.3之前,是Java虚拟机年轻代收集器的唯一选择。
Java中历史最悠久的收集器。
- 单线程收集,GC时必须暂停所有工作线程
- 简单高效,Client默认用这个作为年轻代收集器
工作过程如下图所示:
实际中系统分配给虚拟机管理的内存不会很大,一般就几十兆到一百兆,收集这么多的年轻代的停顿时间也就几十毫秒,一百毫秒左右。只要不是太频繁,这样的停顿是可以接受的。
ParNew收集器
可以在程序启动的时候通过-XX:+UseParNewGC
设置使用此收集器。使用复制算法。
- 除了是多线程收集,其余的行为、特点和Serial收集器一样
- 单核执行效率不如Serial,在多核下执行才有优势
在单核执行的环境中,表现不会比Serial更好,因为存在键程交互开销。但是随着CPU增加,它的表现会更好。它默认开启的收集线程数和CPU数相同。在CPU数量非常多的情况下,可以使用ParGCThreds的参数来限制垃圾收集的线程数
ParNew是Server模式下虚拟机首选的年轻代收集器。因为除了Serial之外,目前只有它可以和CMS收集器配合工作。
ParNew工作过程如下图:
Parallel Scavenge收集器
可以在程序启动的时候通过-XX:+UseParallelGC
设置使用此收集器。使用复制算法。
这个收集器和系统吞吐量有关。
什么是系统的吞吐量?
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
也就是运行用户代码时间/CPU消耗总时间。比如虚拟机一共运行了100分钟,垃圾收集用了2分钟,吞吐量就是98%
Parallel Scavenge收集器有些类似ParaNew收集器,也是多线程,但是与ParNew相比也有不同:
- 相比ParNew,Parallel Scavenge对系统吞吐量的重视程度大于对用户线程停顿的时间的重视程度。虽然停顿时间短比较适合与用户相互的程序,因为响应速度更快可以提升用户体验;但高吞吐量可以高效率利用CPU时间,尽可能快地完成运算任务,比较适合在后台运算而不用和用户交互的任务。
- 在多核下执行才有优势,Server模式下默认的年轻代收集器
Parallel Scavenge和ParNew工作过程基本相同,如下图:
值得一提的是,如果程序员本身对垃圾收集器不太了解,在程序优化过程中遇到了困难的时候,可以这样解决:在启动的时候加上参数-XX:+UseAdaptiveSizePolicy
,使用Parallel Scavenge的自适应调节策略,这样就可以把内存管理的调优任务交给虚拟机自己去完成。
老年代收集器
Serial Old收集器(MSC)
可以在程序启动的时候通过-XX:+UseSerialOldGC
设置使用此收集器。使用标记-整理算法。
Serial模式的老年版
- 单线程收集,进行垃圾收集时,必须暂停所有工作线程
- 简单高效,Client模式下默认的老年代收集器
工作流程如图:
Parallel Old收集器
可以在程序启动的时候通过-XX:+UseParallelOldGC
设置使用此收集器。使用标记-整理算法。
这个收集器在JDK6之后才开始提供的。在此之前新生代的Parallel Scavenge收集器一直处在一个比较尴尬的位置,因为如果新生代选了它,老年代就只能选Serial Old收集器了。
Parallel Old收集器的出现就是为了解决这个问题。
直到Parallel Old出现之后,吞吐量优先收集器才有了名副其实的组合。
- 多线程,吞吐量优先
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge + Parallel Old收集器的组合。
工作流程如图:
CMS收集器
CMS在JDK1.7之前可以说是最主流的垃圾回收算法。CMS使用标记-清除算法,优点是并发收集,停顿小。
可以在程序启动的时候通过-XX:+UseConcMarkSweepGC
设置使用此收集器。使用标记-清除算法。
实际上,CMS收集器几乎占据着JVM老年代收集器的半壁江山。它的划时代的意义就是垃圾回收线程几乎能与用户线程做到同时工作——说是”几乎”,是因为它不能完全做到不”Stop-the-World”,它只是能尽可能地缩短停顿时间。需要注意如果你的程序对停顿比较敏感,并且在应用程序运行的时候可以提供更大的内存和更多的CPU,那么用CMS是好的选择。
此外,如果在JVM中有相对较多而且存活时间较长的对象,也更适合使用CMS。
CMS的算法在整个垃圾回收过程可以分为下面六步:
- 初始标记:这个过程会stop-the-world(简称STW),JVM停顿正在执行的任务,从垃圾回收的根对象开始,只扫描和根对象直接关联的对象,时间短。最后标记的对象只是从root集最直接可达的对象;
- 并发标记:并发追溯标记,程序不停顿。这个阶段中GC线程和应用线程并发执行,主要标记可达的对象。用户不会感受到停顿;
- 并发预清理:查找执行并发标记时晋升老年代的对象。可能有一些对象从新生代晋升到老年代,或者有些对象直接被分配到老年代,通过重新扫描,减少下一个阶段重新标记的工作(因为下一阶段会重新stop-the-world)。这个过程不停顿
- 重新标记:过程会STW,暂停虚拟机,扫描CMS堆中剩余对象,扫描从根对象开始向下追溯,并处理对象单元。这一步相对较慢
- 并发清理:清理垃圾对象,程序不停顿
- 并发重置:重置CMS收集器的数据结构,等待下一次垃圾回收
上述过程中,初始标记和重新标记需要短暂的stop-the-world
工作流程如下图:
并发标记的过程实际上是和用户线程同时工作的,也就是一边丢垃圾,一边打扫。但这也可能产生一个问题,就是某个垃圾如果在打扫之后产生的,那么这个垃圾就只能等到下次垃圾回收才能被收掉,也就是说垃圾打扫完一次后没有完全打扫干净。
但是CMS收集器因为用的是”标记-清除算法”而不是”标记-整理算法”,就不可避免导致了垃圾碎片化的问题。如果此时需要分配较大的对象,那就只能触发一次GC了。
优点:并发,停顿低
年轻代和老年代都可:G1收集器
G1在JDK1.9之后是JVM默认的垃圾回收算法,G1的特点是保持高回收率的同时,减少停顿。
G1算法对JVM中堆结构进行了改变,取消了年轻代和老年代的物理划分,但它仍然属于分代收集器。G1算法将堆划分为若干个区域,称作 Region,如下图中的小方格所示:
一部分区域用作年轻代,一部分用作老年代,另外还有一种专门用来存储巨型对象的分区。
G1 也和 CMS 一样会遍历全部的对象,然后标记对象引用情况,在清除对象后会对区域进行复制移动整合碎片空间。
G1的回收过程如下:
G1的年轻代回收,采用复制算法,并行收集,过程会SWT
G1的老年代回收时会对年轻代进行回收。主要分为四个阶段:
a. 依然是初始标记阶段完成对象的标记,这个过程是STW的;
b. 并发标记阶段,这个阶段是和用户线程并行执行的;
c. 最终标记阶段,完成三色标记周期;
d. 复制/清除阶段,这个阶段会优先对可回收空间较大的Region进行回收,即garbage first,这也是G1名称的由来。
G1采用每次只清理一部分而不是全部Region的增量式清理,由此来包保证每次GC停顿时间不会过长。
可以在程序启动的时候通过-XX:+UseG1GC
设置使用此收集器。使用多种算法,即”复制算法 + 标记-整理算法“。
G1收集器既用于年轻代,也用于老年代。全称:Garbage First收集器。
实际上,HotSpot最终的目的是让G1收集器最后能替换掉JDK5发布的CMS收集器。
Garbage First收集器的特点:
- 并行和并发——使用多个CPU来缩短stop-the-world的停顿时间,与用户线程并发执行
- 分代收集——独立管理整个堆,但是能够采用不同的方式去处理新创建的对象和已经熬过多次GC的旧对象以获得更好的收集效果
- 空间整合——基于”标记-整理算法”,解决了内存碎片的问题
- 可预测停顿——能建立可预测的停顿时间模型,设置用户在某个地方的停顿时长不能超过m毫秒,类似这样
在Garbage First垃圾收集器之前的收集器,都是只针对年轻代或者老年代的。而Garbage First可以同时针对年轻代和老年代。
在使用Garbage First收集器的时候,Java堆的布局和使用其他垃圾收集器时有很大不同:
- 将整个Java堆内存划分成多个大小相等的Region——虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了。这个Region大小可以通过JVM参数设置,范围是1~32MB
- 年轻代和老年代不再物理隔离——它们可以是不连续的Region的集合。这也使得分配内存空间的时候可以不是连续的
也就是说此时在JVM启动的时候不需要决定哪些Region属于老年代,哪些Region属于年轻代。因为随着时间推移,年轻代的Region被回收以后,就会变为可用状态,此时也可以把它分配成老年代。
和其他的HotSpot一样,当一个年轻代GC发生时,整个年轻代会被回收,G1的老年代收集器有所不同,它在老年代不需要整个老年代进行回收,只有一部分Region被调用。
G1的年轻代由Eden Region和Survivor Region组成。当一个JVM分配Eden Region失败后,会触发一个年轻代回收,意味着Eden区满了。之后GC开始释放空间,第一个年轻代收集器会移动所有的存储对象,从Eden Region到Survivor Region,这就是copy to survivor的过程。
JDK11还有研发Epsilon GC和ZGC,这里暂时先不介绍。
回顾一个问题
如上图,为什么CMS不能和Parallel Scavenge一起工作呢?两者为什么不兼容呢?
CMS是HotSpot在JDK5的时候推出的第一款整整意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作。
CMS作为老年代收集器不能和Parallel Scavenge一起工作主要是因为Parallel Scavenge和CMS代码框架不同。
常用的垃圾回收器组合
- ParNew + CMS组合(以后会被淘汰)
这个是JDK8以及JDK8之前非常常用的组合。
ParNew回收的过程是多线程进行回收,过程是先把存活的对象标记出来,把这些存活对象复制到一块S区,然后清除。
CMS执行的过程就比ParNew复杂多了,而且老年代的回收很多时候会比年轻代慢10倍以上。具体内容参考上面的介绍。
- G1直接回收(JDK1.9及之后的11默认,最后其实希望G1代替ParNew与CMS组合)
17.G1垃圾回收器与CMS的区别有哪些?
参考文章:
正如上面所说,1.9之后已经将G1设置为默认的垃圾回收器。两者区别整理如下:
特征 | G1 | CMS |
---|---|---|
并发和分代 | 是 | 是 |
最大化释放堆内存 | 是 | 否 |
低延时 | 是 | 是 |
吞吐量 | 高 | 低 |
压实 | 是 | 否 |
可预测性 | 强 | 弱 |
新生代和老年代的物理隔离 | 否 | 是 |
CMS收集器特点汇总:
CMS收集器优点:并发收集、低停顿。
CMS收集器缺点:
- CMS收集器对CPU资源非常敏感
- CMS收集器无法处理浮动垃圾(Floating Garbage)
- CMS收集器是基于标记-清除算法,该算法的缺点都有
CMS收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。
G1收集器特点汇总:
- 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
- 分代收集
- 空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
- 可预测的停顿:这是G1相对于CMS的一个优势,降低停顿时间是G1和CMS共同的关注点。
18.什么情况下会触发FullGC?
当触发老年代的垃圾回收的时候,往往也会伴随对新生代堆内存的回收,即对整个堆进行垃圾回收,也就是所谓的Full GC,或者叫做Major GC。Major GC和Full GC是等价的,即收集所有的GC堆。
主要是因为HotSpot VM发展了很多年,外界对很多名词的解读都已经混乱了,当有人说到了”Major GC”的时候,一定要问清楚,他说的到底是针对所有代的Full GC,还是只是针对老年代的GC。
Full GC比Minor GC慢(慢十倍),但因为老年代里面元素本身就不容易被淘汰,所以执行频率也会更低。
触发Full GC的条件
- 老年代空间不足——如果创建的对象很大,Eden区域放不下这个对象,会放入到老年代中。如果老年代空间也不足,就会触发Full GC。为了避免这种情况,最好就是不要创建太大的对象。
- 永久代空间不足——这主要是针对JDK7以及以前的版本。当系统中需要加载和调用的类很多,而同时持久代当中没有足够的空间去存放类的信息和方法信息的时候,就会触发出一次Full GC。而JDK8以后由于取消了永久代,就不存在”永久代空间不足”这种情况了。(这也是JDK8后面用元空间替代永久代的原因之一,为了降低Full GC的频率,减少GC的负担,提升其效率)
- CMS GC时出现promotion failed, concurrent mode failure。对于采用CMS 进行老年代GC的程序而言,如果GC日志中出现了这两个字段。如果出现了,可能会触发Full GC。
- Minor GC晋升到老年代的平均大小大于老年代的剩余空间
- 调用System.gc()——这个是我们在程序里面手动调用的,触发Full GC。需要注意这个方法只是提醒虚拟机,程序员希望你在这里回收一下对象。但是具体怎么做还是要看虚拟机自己,程序员没有控制权
- 使用RMI来进行RPC或管理的JDK应用,每小时执行1次Full GC
这些点很多,面试的时候只要能够提到3点,基本可以点到为止了,可以答到老年代空间不足,程序手动调用System.gc(),然后如果用的JDK版本比较老,在JDK8之前的版本,会有永久代空间不足的情况。当然其他的能说出来更好。
需要注意:
1.promotion failed是在进行Minor GC的时候Survivor放不下了,对象只能放入老年代,而此时恰好老年代也放不下,这时候就会造成promotion failed。
2.concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代中,而此时老年代空间不足,就会造成这个failure。
而对于Minor GC晋升的这第四点,是比较复杂的触发情况。HotSpot为了避免由于新生代对象晋升到老年代而导致老年代空间不足的现象,在进行Minor GC的时候做了一个判断:如果之前统计所得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间,就直接触发Full GC。例如,程序第一次触发GC后有6M的对象晋升到老年代,当下一次Minor GC发生的时候,首先先检查老年代的剩余空间是否大于6M,如果小于6M,则执行Full GC。
19.介绍一下JDK11新推出的ZGC算法?
JDK11提供的最激动人心的就是ZGC这个新的垃圾回收器,ZGC专门为大内存堆设计,有很强悍的性能,能够实现10ms一下的GC暂停时间。
Java内存模型与synchronized和volatile的面试二连击
1.Java内存模型(JMM)
这里简要概括上面写过的Java内存模型的内容。
Java内存模型JMM和JVM是两回事,JVM内存模型主要针对运行时数据区而言,而JMM是指Java程序中变量的访问规则,两者是完全不同的两个概念。
在 JDK1.2 之前,Java的内存模型(JMM)实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前 的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就 可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
新的Java内存模型如下图:
要解决这个问题,就需要把变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行 读取。
说白了, volatile 关键字的主要作用就是保证变量的可见性然后还有一个作用是防止指令重排序。
2.synchronized关键字和volatile关键字的区别
synchronized关键字和volatile关键字比较
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进 行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行 效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访 问资源的同步性。
Java线程池的面试四连击
1.为什么要用线程池?
线程池是为了避免线程频繁的创建和销毁带来的性能消耗,而建立的一种池化技术,它是把已创建的线程放入“池”中,当有任务来临时就可以重用已有的线程,无需等待创建的过程,这样就可以有效提高程序的响应速度。
这里借用《Java并发编程的艺术》提到的来说一下使用线程池的好处:
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性, 使用线程池可以进行统一的分配,调优和监控。
2.实现Runnable接口和Callable接口的区别?
如果想让线程池执行任务的话需要实现的Runnable接口或Callable接口。 Runnable接口或Callable接口实现类都可 以被ThreadPoolExecutor或ScheduledThreadPoolExecutor执行。两者的区别在于 Runnable 接口不会返回结果但 是 Callable 接口可以返回结果。
备注:工具类 Executors
可以实现 Runnable
对象和 Callable
对象之间的相互转换。 ( Executors.callable(Runnable task)
或 Executors.callable(Runnable task,Object resule)
)。
3.执行execute()方法和submit()方法的区别是什么呢?
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否。;
- submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断 任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行 完。
此外它们俩之间另一个区别是,execute()方法属于Executor接口的方法,而submit()方法则是属于ExecutorService接口的方法,它们之间的继承关系如下图所示:
4.如何创建线程池
《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这 样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积 大量的请求,从而导致OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能 会创建大量线程,从而导致OOM。
而且参考之前写过的《ThreadPoolExecutor的核心参数》,那部分已经度过了源代码(能把参数和其作用都具体说出来,效果已经不错了)。其实当我们去看Executors的源码会发现,Executors.newFixedThreadPool()、Executors.newSingleThreadExecutor()和Executors.newCachedThreadPool() 等方法的底层都是通过 ThreadPoolExecutor 实现的。
顺带提一下上面三种基于Executor框架的工具类Executors实现的三种类型的ThreadPoolExecutor:
CAS(比较后替换,Compare and Set)
从synchronized引入
大家可能都听说说 Java 中的并发包,如果想要读懂 Java 中的并发包,其核心就是要先读懂 CAS 机制,因为 CAS 可以说是并发包的底层实现原理。
CAS能够保证操作的原子性,JDK8也对CAS进行了一些优化。
首先来看synchronized,它很多时候有些大材小用了。
先看几行代码:
1 | public class CASTest { |
假如有100个线程同时调用 increment() 方法对 i 进行自增操作,i 的结果会是 100 吗?
但凡对多线程有一点了解的同学应该都知道,这个方法是线程不安全的,由于 i++ 不是一个原子操作,所以是很难得到 100 的。
这里稍微解释下为啥会得不到 100(知道的可直接跳过), i++ 这个操作,计算机需要分成三步来执行。
1、读取 i 的值。
2、把 i 加 1.
3、把 最终 i 的结果写入内存之中。
所以,(1)、假如线程 A 读取了 i 的值为 i = 0,(2)、这个时候线程 B 也读取了 i 的值 i = 0。(3)、接着 A把 i 加 1,然后写入内存,此时 i = 1。(4)、紧接着,B也把 i 加 1,此时线程B中的 i = 1,然后线程 B 把 i 写入内存,此时内存中的 i = 1。也就是说,线程 A, B 都对 i 进行了自增,但最终的结果却是 1,不是 2.
解决方法:加锁。比如可以加synchronized锁
1 | public class CASTest { |
加了 synchronized 之后,就最多只能有一个线程能够进入这个 increment() 方法了,保证了线程安全。
然而,一个简简单单的自增操作,就加了 synchronized 进行同步,加了 synchronized 关键词之后,当有很多线程去竞争 increment 这个方法的时候,拿不到锁的方法是会被阻塞在方法外面的,最后再来唤醒他们,而阻塞/唤醒这些操作,是非常消耗时间的。因为只有一个线程可以成功的对myObject加锁,可以对它关联的monitor的计数器去加1,加锁,一旦多个线程并发的去进行synchronized加锁,相当于串行化执行了,效率并不是太高,其他线程都需要排队去执行
更合适的解决方法:CAS
CAS介绍
大家看一下,如果我采用下面这种方式,能否保证 increment 是线程安全的呢?步骤如下:
1、线程从内存中读取 i 的值,假如此时 i 的值为 0,我们把这个值称为 k 吧,即此时 k = 0。
2、令 j = k + 1。
3、用 k 的值与内存中i的值相比,如果相等,这意味着没有其他线程修改过 i 的值,我们就把 j(此时为1) 的值写入内存;如果不相等(意味着i的值被其他线程修改过),我们就不把j的值写入内存,而是重新跳回步骤 1,继续这三个操作。
翻译成代码的话就是这样:
1 | public static void increment() { |
如果你去模拟一下,就会发现,这样写是线程安全的。
这里可能有人会说,第三步的 compareAndSet 这个操作不仅要读取内存,还干了比较、写入内存等操作,这一步本身就是线程不安全的啊?
如果你能想到这个,说明你是真的有去思考、模拟这个过程,不过我想要告诉你的是,这个 compareAndSet 操作,是由硬件提供的原语来保证其操作的原子性。CAS在底层的硬件级别给你保证一定是原子的,同一时间只有一个线程可以执行CAS,先比较再设置,其他的线程的CAS同时间去执行此时会失败。 即他其实只对应操作系统的一条硬件操作指令,尽管看似有很多操作在里面,但操作系统能够保证他是原子执行的。
对于一条英文单词很长的指令,我们都喜欢用它的简称来称呼他,所以,我们就把 compareAndSet 称为 CAS 吧。
所以,采用 CAS 这种机制的写法也是线程安全的,通过这种方式,可以说是不存在锁的竞争,也不存在阻塞等事情的发生,可以让程序执行的更好。
在 Java 中,也是提供了这种 CAS 的原子类,例如:
AtomicBoolean
AtomicInteger
AtomicLong
AtomicReference
具体如何使用呢?我就以上面那个例子进行改版吧,代码如下:
1 | public class CASTest { |
用AtomicInteger
实现的CAS的实现原理如下图所示,首先是单线程访问的情况:
在incrementAndGet()底层会使用CAS方法。
然后是多线程都要使用这个用AtomicInteger
修饰的对象的时候:
可以看到,这样解决了数据修改的问题,又不需要加synchronized这种比较重的锁,CAS相比之下很轻量。
但是又会有一个问题,就是如果函数的操作返回的就是和原来传入的值相同的值,那线程A可能认为B还没执行完。
举个例子:当线程A即将要执行第三步的时候,线程 B 把 i 的值加1,之后又马上把 i 的值减 1,然后,线程 A 执行第三步,这个时候线程 A 是认为并没有人修改过 i 的值,因为 i 的值并没有发生改变。而这,就是我们平常说的ABA问题。
CAS只能保障一个变量的原子性,但不能保证整个代码块的原子性。
CAS可能会造成ABA问题,即:线程一拿到了某个变量最初的预期原值A,在将要进行CAS的时候被其他线程抢占了执行权,把值从A变成了B,然后其他线程又把这个值从B变回了A。虽然这个时候此变量实际上已经不是原先的A了,但线程一并不知道这个情况,它在执行CAS的时候,对比了A的值没有改变,就按照这个预期工作了,这就造成了ABA问题。
以警匪剧为例,假如某人把装了100W现金的箱子放在了家里,几分钟之后要拿它去赎人,然而在趁他不注意的时候,进来了一个小偷,用空箱子换走了装满钱的箱子,当某人进来之后看到箱子还是一模一样的,他会以为这就是原来的箱子,就拿着它去赎人了,这种情况肯定有问题,因为箱子已经是空的了,这就是 ABA 的问题。
对于基本类型的值来说,这种把数字改变了在改回原来的值是没有太大影响的,但如果是对于引用类型的话,就会产生很大的影响了。
解决方法:版本控制,或者说添加版本号。每次修改之后都更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了ABA问题。
相似的,每次有线程修改了引用的值,就会进行版本的更新,虽然两个线程持有相同的引用,但他们的版本不同,这样,我们就可以预防 ABA 问题了。
JDK在1.5时提供了 AtomicStampedReference
这个类,维护了”版本号” Stamp
,每次,值和版本号,都会比较,这样就解决了ABA的问题。
CAS会遇到的问题及如何解决
CAS虽然高效的解决了原子操作问题,但仍然存在三大问题:
- ABA问题:如果变量V初次读取的时候值是A,后来变成了B,然后又变成了A,你本来期望的值是第一个A才会设置新值,第二个A跟期望不符合,但却也能设置新值。针对这种情况,java并发包中提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本号来保证CAS的正确性,比较两个值的引用是否一致,如果一致,才会设置新值。 打一个比方,如果有一家蛋糕店,为了挽留客户,绝对为贵宾卡里余额小于20元的客户一次性赠送20元,刺激消费者充值和消费。但条件是,每一位客户只能被赠送一次。此时,如果很不幸的,用户正好正在进行消费,就在赠予金额到账的同时,他进行了一次消费,使得总金额又小于20元,并且正好累计消费了20元。使得消费、赠予后的金额等于消费前、赠予前的金额。这时,后台的赠予进程就会误以为这个账户还没有赠予,所以,存在被多次赠予的可能,但使用AtomicStampedReference就可以很好的解决这个问题。
- 无限循环问题(自旋):看源码可知,Atomic类设置值的时候会进入一个无限循环,只要不成功,就会不停的循环再次尝试。在高并发时,如果大量线程频繁修改同一个值,可能会导致大量线程执行compareAndSet()方法时需要循环N次才能设置成功,即大量线程执行一个重复的空循环(自旋),造成大量开销。解决无线循环问题可以使用java8中的LongAdder,分段CAS和自动分段迁移。
- 多变量原子问题:只能保证一个共享变量的原子操作。一般的Atomic类,只能保证一个变量的原子性,但如果是多个变量呢?可以用AtomicReference,这个是封装自定义对象的,多个变量可以放一个自定义对象里,然后他会检查这个对象的引用是不是同一个。如果多个线程同时对一个对象变量的引用进行赋值,用AtomicReference的CAS操作可以解决并发冲突问题。 但是如果遇到ABA问题,AtomicReference就无能为力了,需要使用AtomicStampedReference来解决。
JDK8对CAS的优化
由于采用这种 CAS 机制是没有对方法进行加锁的,所以,所有的线程都可以进入 increment() 这个方法,假如进入这个方法的线程太多,就会出现一个问题:每次有线程要执行第三个步骤的时候,i 的值老是被修改了,所以线程又到回到第一步继续重头再来。
而这就会导致一个问题:由于线程太密集了,太多线程想要修改 i 的值了,进而大部分线程都会修改不成功,白白着在那里循环消耗资源。
为了解决这个问题,Java8 引入了一个 cell[] 数组,它的工作机制是这样的:假如有 5 个线程要对 i 进行自增操作,由于 5 个线程的话,不是很多,起冲突的几率较小,那就让他们按照以往正常的那样,采用 CAS 来自增吧。
但是,如果有 100 个线程要对 i 进行自增操作的话,这个时候,冲突就会大大增加,系统就会把这些线程分配到不同的 cell 数组元素去,假如 cell[10] 有 10 个元素吧,且元素的初始化值为 0,那么系统就会把 100 个线程分成 10 组,每一组对 cell 数组其中的一个元素做自增操作,这样到最后,cell 数组 10 个元素的值都为 10,系统在把这 10 个元素的值进行汇总,进而得到 100,二这,就等价于 100 个线程对 i 进行了 100 次自增操作。
CAS和synchronized各自适用场景
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)。
AQS
AQS的原理就是提供了一个volatile修饰的状态变量和一个双向的同步队列。提供模板方法对于独占锁和共享锁的获取和释放.
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。AQS本质是几个变量加上一个等待队列。
正式介绍AQS
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面,AQS是各种各样锁的基础,比如说 ReentrantLock、CountDownLatch 等等,这些我们经常用的锁底层实现都是AQS。
AQS是基于CAS的锁同步框架,是一个抽象类,其中有一个状态统计变量stateOffset是使用CAS来操作的。
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask(一些具体作用见下段)等等皆是 基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。
参考《Java并发编程实战》的内容:
AQS管理一个关于状态信息的单一整数,状态信息可以通过protected类型的getState()、setState()和compareAndSetState()等方法操作,这可以用于表现任何状态。
例如,ReentrantLock用它来表现拥有它的线程已经请求了多少次锁;Semaphore用它来表现剩余的许可次数;FutureTask用它来表现任务的状态(尚未开始、运行、完成和取消)。
AQS核心思想
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结 点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
AQS原理图如下:
AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该 同步状态进行原子操作实现对其值的修改。
AQS两种资源共享方式
- Exclusive(独占):只有一个线程能执行,比如ReentrantLock。又可以分为公平锁和非公平锁。
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁的时候,无视队列顺序直接去抢锁,谁先抢到就是谁的。
- Share(共享)::多个线程可同时执行,如Semaphore/CountDownLatch。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方 式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
AQS组件总结
- Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问 某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
- CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这 个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
- CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待, 但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫 同步点)时被阻塞,直到后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。 CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
谈谈对多线程的锁的理解?如何手动模拟一个死锁?
多线程并发编程有两个重要概念:线程和锁。虽然多线程可以提高程序性能,但是带来了编码的复杂性,尤其是要解决多线程操作同一组资源的时候保证数据的一致性的问题。引入了锁之后,我们会给某个资源上锁,只有拥有这个资源的锁的线程才能操作此资源,而其他线程只能排队等待使用锁,也就是要等待这个锁被释放才行。
在并发编程中,当有多个线程同时操作一个资源时,为了保证数据操作的正确性,需要让多线程排队,一个一个地操作资源,而这个过程就是给资源加锁和释放锁的过程,就好像去公共厕所一样,一次只能有一个人占用,剩下的必须排队,而且一个人用的时候为了防止后面人进来,要给门上锁一样。
什么是死锁?如何手动模拟一个死锁?
首先,死锁是指两个线程都各自拥有一个资源的锁,但是又都在等待对方释放对方锁拥有的资源的锁。如下图所示:
代码例子如下:
1 | import java.util.concurrent.TimeUnit; |
上述代码执行结果如下:
1 | 获取lock1成功 |
在分别获得lock1和lock2成功之后,这两个线程还会试图获得对方的锁,也就是线程一拥有锁lock1之后试图获取lock2,而线程二在拥有lock2之后试图获取lock1,这样就造成了彼此都在等待对方释放资源,于是造成了死锁。
Java的悲观锁和乐观锁、共享锁和独占锁
悲观锁
悲观锁是指数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。通俗理解,就是在用数据之前要先加上锁,用完之后才能释放锁。
拿synchronized来说,每次执行被synchronized修饰的代码块在执行之前会先用monitorenter
指令加上锁,在执行结束之后再使用monitorexit
指令释放锁资源,在整个执行期间这个代码都是锁定的状态,这就是典型悲观锁的实现流程。
乐观锁
乐观锁肯定与悲观锁相反啦,乐观锁认为数据之间一般不会发生冲突,所以在数据访问之前不会加锁,只有在提交的时候,才会对数据进行检测和上锁。
乐观锁大部分都是通过CAS(Compare And Swap,比较并交换)操作实现的。CAS之前有过介绍,它是一个多线程同步的原子指令。CAS操作包含三个重要信息:内存位置、预期原值和新值。如果内存位置的值和预期原值相等的话,可以把该位置的值更新为新值,否则不做任何修改。之前讲过的ReentrantLock就是通过CAS实现的,Lock就是乐观锁的典型例子。
然而,CAS可能造成ABA问题(前面有介绍过,解决方法是用版本号)。
共享锁和独占锁(排它锁)
可以被多线程持有的称作共享锁
只能被单线程持有的锁称作独占锁。独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 ReentrantLock 就是独占锁,而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。
独占锁可以理解为悲观锁,当每次访问资源时都要加上互斥锁,而共享锁可以理解为乐观锁,它放宽了加锁的条件,允许多线程同时访问该资源。
排它锁的意思是同一时刻,只能有一个线程可以获得锁,也只能有一个线程可以释放锁。共享锁可以允许多个线程获得同一个锁,并且可以设置获取锁的线程数量。
可重入锁
可重入锁也叫递归锁,指的是同一个线程,如果外层的函数拥有这个锁,内层的函数可以继续获取该锁。
在Java语言中,ReentrantLock和synchronized都是可重入锁。
用代码举一个可重入锁的例子,用synchronized来演示:
1 | public class LockExample { |
以上代码执行结果:
1 | main:执行 reentrantA |
从结果可以看出reentrantA方法和reentrantB方法的执行线程都是“main”,我们调用了reentrantA方法,它的方法中嵌套了reentrantB,如果synchronized是不可重入的话,那么线程会一直堵塞。
可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为0,当被线程占用和重入时分别加1,当锁被释放时计数器减1,直到减到 0 时表示此锁为空闲状态。
7.Spring
注解是什么?元注解是什么?有什么用?
答:①注解是一种标记,可以使类或接口附加额外的信息,是帮助编译器和JVM完成一些特定功能的。
②元注解就是自定义注解的注解,包括@Target
:用来约束注解的位置,值是ElementType
枚举类,包括METHOD
方法、VARIABLE
变量、TYPE
类/接口、PARAMETER
方法参数、CONSTRUCTORS
构造器和LOACL_VARIABLE
局部变量;@Rentention
:用来约束注解的生命周期,值是RetentionPolicy
枚举类,包括:SOURCE
源码、CLASS
字节码和RUNTIME
运行时;@Documented
:表明这个注解应该被javadoc工具记录;@Inherited
:表面某个被标注的类型是被继承的。
说说你对Spring IOC机制的理解
Spring IOC的演进由来
首先,我们可以对比,没有IOC的常规情况:
没有IOC的项目模式:tomcat最初的流程是接受http请求,然后封装后转发给我们自己写的servlet,最后由我们手动创建实现类的对象去执行业务逻辑。但是这样存在很大弊端:耦合性太高。比如我要修改这个方法,那么所有调用了这个方法的类全都要改代码!改动过程复杂不说,后序测试也会非常复杂!出了一点点问题,都会要改动很多代码!很不合理。
上面这个过程如图所示:
紧接着,整个体系进行演化,演化出了Spring IOC框架,即控制反转,依赖注入(DI)。在以前,我们都会用xml文件来进行配置我们的系统,现在都是用注解来完成。具体做法:把原先完成业务逻辑的Servlet修改成Controller,在最上面加上注解”@Controller”,然后实现这个Controller的类加上注解”@Resource”,后面我们只要在这个工程里通过maven引入一些spring框架的依赖比如ioc功能,那么tomcat在启动的时候,直接会启动spring容器。
spring容器启动后会扫描所有代码,之后的过程是,spring容器会理解你的注解或者xml配置,只要你加了”@Controller“注解,spring容器就会知道现在需要它来创建你这个类的对象实例;你加了”@Service“,spring容器就会管理和创建这个类;如果加了”@Resource“,就知道这个是一个引用变量,没有对象实例,这个对象实例的bean是不是在spring容器的管理之下,如果在,就把之前创建的带有”@Service“的类赋值给”@Resource“的变量,也就是让”@Controller“去引用”@Service“。
然后引入spring mvc其实就是由它实现了之前servlet的一些功能,处理包装请求,然后一些filter,最终转发到我们的controller,最终调用实现类完成业务逻辑。 整个IOC容器就像是一个map,key是对应的名称,value是通过反射创建的bean。比较明显的作用就是解耦。
样例代码:
1 |
|
上面这段代码中,MyServiceImpl 和 NewServiceManagerImpl 是一前一后实现MyService这个接口的实现类。可以假设原本项目中的实现方法是MyServiceImpl,但是后面改成了NewServiceManagerImpl ,但是此时修改代码非常简单,只需要改一下”@Service”的位置即可,就会改变实例化的方法。但项目要修改的时候,不用改代码,只要改之前的”@Service”注解即可。
上述代码的执行过程如图:
IOC底层核心技术是反射,直接根据你的类去自己构建对应的对象出来。
spring容器会根据xml配置或者注解,去实例化对应的bean对象,再然后根据xml配置或者注解,去对bean对象之间的引用关系,去进行依赖注入,某个bean依赖了另外一个bean。最后spring ioc实现了系统的类与类之间彻底的解耦合。现在这套比较高大上的一点系统里,有几十个类都使用了@Resource这个注解去标注MyService myService,几十个地方都依赖了这个类,如果要修改实现类为NewServiceManagerImpl
Tomcat启动后会去启动一个spring容器,由spring将对应的bean进行创建于初始化,并且管理对应的依赖关系,这里的就是控制反转了,将类的调用关系由主动变成了被动,交给了spring去管理。 3、有了这个机制以后,就可以轻松的完成解耦,然后引入spring mvc其实就是由它实现了之前servlet的一些功能,处理包装请求,然后一些filter,最终转发到我们的controller,最终调用实现类完成业务逻辑。 4、整个IOC容器就像是一个map,key是对应的名称,value是通过反射创建的bean。比较明显的作用就是解耦。
Spring IOC的优点?
IOC 或 依赖注入把应用的代码量降到最低。它使应用容易测试,单元测试不再需要单例和JNDI查找机制。最小的代价和最小的侵入性使松散耦合得以实现。IOC容器支持加载服务时的饿汉式初始化和懒加载。
SpringIOC初始化过程(重要)
详细来说,其实Spring IOC初始化过程就是容器的初始化过程。
之前介绍Spring Bean的时候已经提到了,Spring Bean都是由Spring容器去管理的,而Spring主要有两个容器系列:
- 实现BeanFactory接口的简单容器;
- 实现ApplicationContext接口的高级容器。
ApplicationContext比较复杂,它不但继承了BeanFactory的大部分属性,还继承其它可扩展接口,扩展的了许多高级的属性,其接口定义如下:
1 | public interface ApplicationContext extends EnvironmentCapable, |
在BeanFactory子类中有一个DefaultListableBeanFactory类,它包含了基本Spirng IoC容器所具有的重要功能,开发时不论是使用BeanFactory系列还是ApplicationContext系列来创建容器基本都会使用到DefaultListableBeanFactory类,可以这么说,在spring中实际上把它当成默认的IoC容器来使用。
关于Spirng IoC容器的初始化过程在《Spirng技术内幕:深入解析Spring架构与设计原理》一书中有明确的指出,IoC容器的初始化过程可以分为三步:
- Resource定位(Bean的定义文件定位)
- 将Resource定位好的资源载入到BeanDefinition
- 将BeanDefiniton注册到容器中
如下图:
Spring实现IOC的三种方式
属性注入
通过setXXX()方法注入Bean的属性值或者依赖对象,最常用。
- Spring首先会调用bean的默认构造函数实例化bean对象,然后再通过反射的方法来调用set方法来注入属性值。
- 属性注入要求bean提供一个默认的构造函数,并且得为需要注入的属性提供set方法
构造函数注入(构造器依赖注入)
使用构造函数注入的前提是:bean必须提供带参的构造函数。
构造器依赖注入通过容器触发一个类的构造器来实现的,该类有一系列参数,每个参数代表一个对其他类的依赖。
工厂方法注入
注解的方式注入 @Autowired,@Resource,@Required。
@Autowired和@Resource的区别(重要)
简而言之:@Autowired按byType
(类型)自动注入,而@Resource则默认按byName
(名字)自动注入,当然@Resource可以指定按照什么方式注入。
@Autowired是在Spring2.5后引入的,它可以对类成员变量、方法及构造函数进行标注,完成自动装配的工作。 可以通过 @Autowired的使用来消除 set ,get方法。
@Resource有两个属性是比较重要的,分别是name
和type
,Spring 将@Resource注解的name
属性解析为bean的名字,而type
属性则解析为bean的类型。因此,如果使用name
属性,则使用byName
的自动注入策略,而使用type
属性时则使用byType自动注入策略。如果既不指定name
也不指定type
属性,这时将通过反射机制使用byName
自动注入策略。
说说你对Spring AOP机制的理解
spring核心框架里面,最关键的两个机制,就是ioc和aop,spring IOC可以根据xml配置或者注解,去实例化我们所有的bean,管理bean之间的依赖注入,让类与类之间解耦,维护代码的时候可以更加的轻松便利。
下面介绍Spring AOP。
AOP是一个编程目标,”面向切面编程”的目标。实现这个目标,有哪些手段?——其中一个手段是Spring AOP,还有一个手段是AspectJ。Spring在2.5之后借用了AspectJ的语法,借鉴了其编程风格
为什么需要AOP?
AOP可以说是对OOP的补充和完善。OOP引入封装、继承和多态性等概念来建立一种对象层次结构,用以模拟公共行为的一个集合。当我们需要为分散的对象引入公共行为的时候,OOP则显得无能为力。也就是说,OOP允许你定义从上到下的关系,但并不适合定义从左到右的关系。
例如日志功能。日志代码往往水平地散布在所有对象层次中,而与它所散布到的对象的核心功能毫无关系。在OOP设计中,它导致了大量代码的重复,而不利于各个模块的重用。
将程序中的交叉业务逻辑(比如安全检查,日志,事务管理、缓存、对象池等),封装成一个切面,然后注入到目标对象(具体业务逻辑)中去。
代理模式(AOP实现原理)
之所以这里单独列出来代理模式,主要因为AOP就是通过代理模式实现的(静态代理、动态代理)
代理模式是23种设计模式的一种,他是指一个对象A通过持有另一个对象B,可以具有B同样的行为的模式。为了对外开放协议,B往往实现了一个接口,A也会去实现接口。但是B是“真正”实现类,A则比较“虚”,他借用了B的方法去实现接口的方法。A虽然是“伪军”,但它可以增强B,在调用B的方法前后都做些其他的事情。Spring AOP就是使用了动态代理完成了代码的动态“织入”。
使用代理好处还不止这些,一个工程如果依赖另一个工程给的接口,但是另一个工程的接口不稳定,经常变更协议,就可以使用一个代理,接口变更时,只需要修改代理,不需要一一修改业务代码。从这个意义上说,所有调外界的接口,我们都可以这么做,不让外界的代码对我们的代码有侵入,这叫防御式编程。代理其他的应用可能还有很多。
上述例子中,类A写死持有B,就是B的静态代理。如果A代理的对象是不确定的,就是动态代理。
动态代理目前有两种常见的实现,jdk动态代理和cglib动态代理。
静态代理的实现有两种——组合和继承。一种通过组合的方式获取目标函数,一个通过继承目标类通过父类调用函数。(如果用面向对象的思路来看,能用组合就尽量用组合,因为继承出了情况的话修改代码太麻烦)。
AOP实现原理
AOP 实现的关键就在于 AOP 框架自动创建的 AOP 代理,AOP 代理则可分为静态代理和动态代理两大类,其中静态代理是指使用 AOP 框架提供的命令进行编译,从而在编译阶段就可生成 AOP 代理类,因此也称为编译时增强;而动态代理则在运行时借助于 JDK 动态代理、CGLIB 等在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。
实现AOP(不是Spring AOP,而是AOP技术,即Spring AOP和AspectJ)的技术,主要分为两大类:
- 一是采用动态代理技术(Spring AOP),利用截取消息的方式,对该消息进行装饰,以取代原有对象行为的执行;
- 二是采用静态织入(静态代理)的方式(AspectJ),引入特定的语法创建“切面”,从而使得编译器可以在编译期间织入有关“切面”的代码.
JDK和CGLib动态代理性能对比
JDK动态代理基于接口、CGLib基于子类。
关于两者之间的性能的话,JDK动态代理所创建的代理对象,在以前的JDK版本中,性能并不是很高,虽然在高版本中JDK动态代理对象的性能得到了很大的提升,但是他也并不是适用于所有的场景。主要体现在创建的实例对象的性能和创建对象需要花费的时间这两个指标中:
1、CGLib所创建的动态代理对象在实际运行时候的性能要比JDK动态代理高不少,有研究表明,大概要高10倍;
2、但是CGLib在创建对象的时候所花费的时间却比JDK动态代理要多很多,有研究表明,大概有8倍的差距;
因此,对于singleton的代理对象或者具有实例池的代理,因为无需频繁的创建代理对象,所以比较适合采用CGLib动态代理,反之,则比较适用JDK动态代理。
除了创建对象的性能和花费的时间之外,其他差别:
- JDK动态代理需要实现接口,而CGlib没有这个限制。但是CGlib不能代理final类或者final方法
Spring AOP中的JDK和CGLib动态代理哪个效率更高?
画一张图说说Spring的核心架构?
spring容器的执行的核心原理实际上可以用bean的流程来表述。
spring和新架构比较简单,但是实现细节很复杂。你在系统里用xml或者注解,定义一大堆的bean,然后整个过程就是对bean进行操作。
比如你要用AOP进行一些增强,可能用到动态代理,或者IOC的反射,得到bean的对象实例。
Spring Bean是什么
被称作 bean 的对象是构成应用程序的支柱也是由 Spring IoC 容器管理的。bean 是一个被实例化,组装,并通过 Spring IoC 容器所管理的对象。这些 bean 是由用容器提供的配置元数据创建。
一个Spring Bean定义包含什么
bean 定义包含称为配置元数据的信息。
Spring IOC容器管理Bean时,需要了解Bean的类名、名称、依赖项、属性、生命周期及作用域等信息。为此,Spring IOC提供了一系列配置项,用于Bean在IOC容器中的定义。已创建的Bean类需要在Spring配置文件中进行定义,Spring IOC容器才能对Bean进行组配和管理。
一些配置项如下表所示:
Spring的两种IOC容器——BeanFactory和ApplicationContext
Spring 容器是 Spring 框架的核心。容器将创建对象,把它们连接在一起,配置它们,并管理他们的整个生命周期从创建到销毁。Spring 容器使用依赖注入(DI)来管理组成一个应用程序的组件。这些对象被称为 Spring Beans.
通过阅读配置元数据提供的指令,容器知道对哪些对象进行实例化,配置和组装。配置元数据可以通过 XML,Java 注释或 Java 代码来表示。下图是 Spring 如何工作的高级视图。
Spring IoC 容器利用 Java 的 POJO 类和配置元数据来生成完全配置和可执行的系统或应用程序。
Spring提供了两种不同类型的容器:
- BeanFactory容器,最简单的容器(太简单了,功能实现比较少,不推荐了),给DI提供了基本支持。
- ApplicationContext容器,该容器添加了更多的企业特定的功能,例如从一个属性文件中解析文本信息的能力,发布应用程序事件给感兴趣的事件监听器的能力。该容器是由 org.springframework.context.ApplicationContext 接口定义。
两者区别:ApplicationContext 容器包括 BeanFactory 容器的所有功能,所以通常建议使用 ApplicationContext。BeanFactory 仍然可以用于轻量级的应用程序,如移动设备或基于 applet 的应用程序,其中它的数据量和速度是显著。
Spring的基本模块——BeanFactory(BeanFactory容器)
BeanFactory是基本的Spring模块,提供spring 框架的基础功能,BeanFactory 是 任何以spring为基础的应用的核心。Spring 框架建立在此模块之上,它使Spring成为一个容器。
Bean 工厂是工厂模式的一个实现,提供了控制反转功能,用来把应用的配置和依赖从真正的应用代码中分离。
最常用的BeanFactory 实现是XmlBeanFactory 类。即org.springframework.beans.factory.xml.XmlBeanFactory ,它根据XML文件中的定义加载beans。这个容器从一个 XML 文件中读取配置元数据,由这些元数据来生成一个被配置化的系统或者应用。
需要注意,在资源宝贵的移动设备或者基于 applet 的应用当中, BeanFactory 会被优先选择。否则,一般使用的是 ApplicationContext,除非你有更好的理由选择 BeanFactory。
ApplicationContext是什么?通常的实现是什么?
Application Context 是 spring 中较高级的容器。和 BeanFactory 类似,它可以加载配置文件中定义的 bean,将所有的 bean 集中在一起,当有请求的时候分配 bean。 另外,它增加了企业所需要的功能,比如,从属性文件从解析文本信息和将事件传递给所指定的监听器。这个容器在 org.springframework.context.ApplicationContext interface 接口中定义。
- FileSystemXmlApplicationContext :此容器从一个XML文件中加载已被定义的bean。在这里,你需要提供给构造器 XML 文件的完整路径
- ClassPathXmlApplicationContext:该容器从 XML 文件中加载已被定义的 bean。在这里,你不需要提供 XML 文件的完整路径,只需正确配置 CLASSPATH 环境变量即可,因为,容器会从 CLASSPATH 中搜索 bean 配置文件。
- WebXmlApplicationContext:此容器加载一个XML文件,此文件定义了一个WEB应用的所有bean。
BeanFactory和ApplicationContext有什么区别?
ApplicationContext 包含 BeanFactory 所有的功能,一般情况下,相对于 BeanFactory,ApplicationContext 会被推荐使用。BeanFactory 仍然可以在轻量级应用中使用,比如移动设备或者基于 applet 的应用程序。
ApplicationContext
在BeanFactory
的基础上实现的额外功能:
默认初始化所有的Singleton,也可以通过配置取消预初始化。
继承MessageSource,因此支持国际化。
继承ResourceLoader,可以资源访问,比如访问URL和文件。
事件机制,比如发送消息、相应机制(ApplicationEventPublisher)。
可以载入多个(有继承关系)的上下文,使得每一个上下文都专注于一个特定的层次,比如应用的web层。
支持Spring的AOP(最常用的是拦截器,一般实现
HandlerInterceptor
,其中有三个方法:preHandle、postHandle、afterCompletion)。
注:由于ApplicationContext会预先初始化所有的Singleton Bean,于是在系统创建前期会有较大的系统开销,但一旦ApplicationContext初始化完成,程序后面获取Singleton Bean实例时候将有较好的性能。也可以为bean设置lazy-init属性为true,即Spring容器将不会预先初始化该bean。
参考文章:BeanFactory和ApplicationContext的区别
Spring Bean加载机制
将Bean类添加到Spring IOC容器有三种方式
- 一种方式是基于XML的配置文件;(现在很不推荐用,太久远了)
- 一种方式是基于注解的配置(Annotation-based configuration,从Spring2.5开始);
- 一种方式是基于Java的配置(java-based configuration, 从Spring3.0开始)
Spring Bean的生命周期(重要)
理解 Spring bean 的生命周期很容易。当一个 bean 被实例化时,它可能需要执行一些初始化使它转换成可用状态。同样,当 bean 不再需要,并且从容器中移除时,可能需要做一些清除工作。
尽管还有一些在 Bean 实例化和销毁之间发生的活动,但是本章将只讨论两个重要的生命周期回调方法,它们在 bean 的初始化和销毁的时候是必需的。
为了定义安装和拆卸一个 bean,我们只要声明带有 init-method 和/或 destroy-method 参数的 。init-method 属性指定一个方法,在实例化 bean 时,立即调用该方法。同样,destroy-method 指定一个方法,只有从容器中移除 bean 之后,才能调用该方法。
首先大体看一下,Spring Bean的生命周期可以分为八步:
(1)实例化Bean:如果要使用一个bean的话会实例化这个Bean
(2)设置对象属性(依赖注入):spring容器需要去看看,你的这个bean依赖了谁,把你依赖的bean也创建出来,给你进行一个注入,比如说通过构造函数,setter
举个例子,代码如下:
1 | public class MyService { |
(3)处理并调用Aware接口:如果这个Bean已经实现了ApplicationContextAware
接口,spring容器就会调用我们的bean的setApplicationContext(ApplicationContext)方法,传入Spring上下文,把spring容器给传递给这个bean
(4)调用BeanPostProcessor方法:如果我们想在bean实例构建好了之后,此时在这个时间点,我们想要对Bean进行一些自定义的处理,那么可以让Bean实现了BeanPostProcessor
接口,那将会调用postProcessBeforeInitialization(Object obj, String s)
方法。
(5)InitializingBean 与 init-method:如果Bean在Spring配置文件中配置了 init-method 属性,则会自动调用其配置的初始化方法。
(6)如果这个Bean实现了BeanPostProcessor接口,将会调用postProcessAfterInitialization(Object obj, String s)方法。这步完成之后,整个Bean就已经初始化好了,后面开始使用。
(7)DisposableBean:当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean这个接口,会调用其实现的destroy()方法;
(8)destory-method:最后,如果这个Bean的Spring配置中配置了destroy-method属性,会自动调用其配置的销毁方法。
整个过程大体描述:创建+初始化一个bean -> spring容器管理的bean长期存活 -> 销毁bean(两个回调函数)
更详细地来看,Spring Bean的生命周期可以分得更细:
Spring Bean的生命周期 ,这篇文章也包含案例
ApplicationContext Bean的生命周期
ApplicationContext容器中,Bean的生命周期流程如上图所示,流程大致如下:
1.首先容器启动后,会对scope为singleton且非懒加载的bean进行实例化,
2.按照Bean定义信息配置信息,注入所有的属性,
3.如果Bean实现了BeanNameAware接口,会回调该接口的setBeanName()方法,传入该Bean的id,此时该Bean就获得了自己在配置文件中的id,
4.如果Bean实现了BeanFactoryAware接口,会回调该接口的setBeanFactory()方法,传入该Bean的BeanFactory,这样该Bean就获得了自己所在的BeanFactory,
5.如果Bean实现了ApplicationContextAware接口,会回调该接口的setApplicationContext()方法,传入该Bean的ApplicationContext,这样该Bean就获得了自己所在的ApplicationContext,
6.如果有Bean实现了BeanPostProcessor接口,则会回调该接口的postProcessBeforeInitialzation()方法,
7.如果Bean实现了InitializingBean接口,则会回调该接口的afterPropertiesSet()方法,
8.如果Bean配置了init-method方法,则会执行init-method配置的方法,
9.如果有Bean实现了BeanPostProcessor接口,则会回调该接口的postProcessAfterInitialization()方法,
10.经过流程9之后,就可以正式使用该Bean了,对于scope为singleton的Bean,Spring的ioc容器中会缓存一份该bean的实例,而对于scope为prototype的Bean,每次被调用都会new一个新的对象,期生命周期就交给调用方管理了,不再是Spring容器进行管理了
11.容器关闭后,如果Bean实现了DisposableBean接口,则会回调该接口的destroy()方法,
12.如果Bean配置了destroy-method方法,则会执行destroy-method配置的方法,至此,整个Bean的生命周期结束
BeanFactory Bean的生命周期
BeanFactoty容器中, Bean的生命周期如上图所示,与ApplicationContext相比,有如下几点不同:
1.BeanFactory容器中,不会调用ApplicationContextAware接口的setApplicationContext()方法,
2.BeanPostProcessor接口的postProcessBeforeInitialzation()方法和postProcessAfterInitialization()方法不会自动调用,必须自己通过代码手动注册
3.BeanFactory容器启动的时候,不会去实例化所有Bean,包括所有scope为singleton且非懒加载的Bean也是一样,而是在调用的时候去实例化。
哪些是重要的bean生命周期方法? 你能重载它们吗?(setup和teardown)
有两个重要的bean 生命周期方法,第一个是setup , 它是在容器加载bean的时候被调用。第二个方法是 teardown 它是在容器卸载类的时候被调用。
bean 标签有两个重要的属性(init-method和destroy-method)。用它们你可以自己定制初始化和注销方法。它们也有相应的注解(@PostConstruct和@PreDestroy)。
换句话说:为了定义安装和拆卸一个 bean,我们只要声明带有 init-method 和/或 destroy-method 参数的 。init-method 属性指定一个方法(setup),在实例化 bean 时,立即调用该方法。同样,destroy-method 指定一个方法(teardown),只有从容器中移除 bean 之后,才能调用该方法。
Bean的作用域
在IoC容器启动之后,并不会马上就实例化相应的bean,此时容器仅仅拥有所有对象的BeanDefinition(BeanDefinition:是容器依赖某些工具加载的XML配置信息进行解析和分析,并将分析后的信息编组为相应的BeanDefinition)。只有当getBean()调用时才是有可能触发Bean实例化阶段的活动。
当在 Spring 中定义一个 时,你必须声明该 bean 的作用域的选项。例如,为了强制 Spring 在每次需要时都产生一个新的 bean 实例,你应该声明 bean 的作用域的属性为 prototype。同理,如果你想让 Spring 在每次需要时都返回同一个bean实例,你应该声明 bean 的作用域的属性为 singleton。
Spring 框架支持以下五个作用域,如果你使用 web-aware ApplicationContext 时,其中三个是可用的。
如下表:
在实际开发中,用得最多的是singleton,prototype偶尔也会用。也就是说,大部分情况每个bean都只有一个实例。99.99%的时候用得都是singleton。
Spring中的bean是线程安全的么?(重要)
首先,这个答案是不一定,Spring框架中的单例bean不一定是线程安全的。如果针对的是在Bean中放入的线程不安全的状态变量,比如int data,然后执行线程不安全的操作,比如data++,那么就是线程不安全的(这个可以结合Bean的作用域默认为singleton)。但是,如果在Bean里面放入线程安全的状态变量,比如ConcurrentHashMap
,那么系统是线程安全的。
要讲清楚spring bean是不是线程安全的,要先讲一下Bean的作用域(上一节已经讲了)。bean默认的作用域是singleton,即单例,也是99.99%的情况下使用。
虽然一般情况下@Controller类不会有自己的变量,但是如果定义了,比如定义了一个data
,那么每次执行data++
的时候,是线程不安全的。
代码如下:
1 |
|
因为MyServiceImpl只有一个对象实例(Spring Bean的singleton),而data是这个实例中的一个比量,所以在面对多线程执行的时候,是有可能出现多线程访问造成的data值出错的情况的。
需要注意Tomcat本身是会开多线程的,可能同时有多个请求发送,多个线程可能会同时调用同一个bean的对象实例,就是这里的data,而且都执行data++,则可能会出错。
但是,对于java web系统来说,一般来说很少在spring bean里放一些实例变量,一般来说他们都是多个组件互相调用,最终去访问数据库的。也就是说一般不会在Spring 的Service和Controller组件里面定义变量,所以大部分变量没有所谓的状态。因此虽然Spring Bean单例下是线程不安全的,但是多个线程不会访问内存中的共享数据,而大部分情况是会造成多个线程并发通过实例访问数据库,并不会对实例的状态变量有什么影响。
Spring Bean小结
项目中的事务处理组件和实体类(POJO)可以作为Bean类,Bean类需要在Spring配置文件中进行定义,才能被IOC容器管理和组配。Spring IOC容器管理Bean时,需要了解Bean的类名、名称、依赖项、属性、生命周期及作用域等信息。为此,Spring IOC提供了一系列配置项,用于Bean在IOC容器中的定义。
Spring事务机制
首先,加上了一个@Transactional注解,就会开启事务。此时就spring会使用AOP思想,对你的这个方法在执行之前,先去开启事务,执行完毕之后,根据你方法是否报错,来决定回滚还是提交事务。
这是Spring针对JDBC来设计的,事务和数据库的事务定义相同。
关于事务,老生常谈的就是ACID,然后三种事务并发可能的问题:脏读、不可重复读、幻读。针对这三种问题,设计了四种解决的隔离级别:READ-UNCOMMITTED、READ-COMMITTED、REPEATABLE-READ、SERIALIZABLE。
Spring 事务中的隔离级别
而Spring的事务隔离级别就是根据上面四种隔离级别而设置的。加上”默认”的级别,一共有五种级别。具体说,TransactionDefinition 接口中定义了五个表示隔离级别的常量:
TransactionDefinition.ISOLATION_DEFAULT: 使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLE_READ隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别.
- TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 低的隔离级别,允许读取尚未提交的数据变 更,可能会导致脏读、幻读或不可重复读
TransactionDefinition.ISOLATION_READ_COMMITTED: 允许读取并发事务已经提交的数据,可以阻止脏 读,但是幻读或不可重复读仍有可能发生
TransactionDefinition.ISOLATION_REPEATABLE_READ: 对同一字段的多次读取结果都是一致的,除非数据 是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
TransactionDefinition.ISOLATION_SERIALIZABLE: 高的隔离级别,完全服从ACID的隔离级别。所有的事 务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻 读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。
Spring事务中的事务传播行为
Spring事务的传播机制是比较关键的一个内容。
举例,我现在有两个方法,方法A和方法B:
1 | (propagation = Propagation.REQUIRED) |
两个方法都带有@Transactional
注解,他们之间调用的时候,事务是怎样传播的呢?
下面讲解某个事务类型的时候,实际上所有类型的变量都是属于TransactionDefinition
中的,比如PROPAGATION_REQUIRED,实质上应该是TransactionDefinition.PROPAGATION_REQUIRED
,但是为了方便阅读,统一省略掉前面的TransactionDefinition
Spring中一共有七种事务。
支持当前事务的情况:
- PROPAGATION_REQUIRED: 如果当前存在事务,则加入该事务;如果当前没有事 务,则创建一个新的事务。 如果什么都不设置,只写上一个
@Transactional
,那么默认就是这个事务,实际上这个也是最常用的。
举例,代码如下:
1 | (propagation = Propagation.REQUIRED) |
如果直接执行了methodB,因为没有执行A,所以B会自己开启一个事务。
但是如果先执行了methodA,则此时执行的流程会是:
1 | // 开启一个事务 |
- PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。 (比较少用)
举例,代码如下:
1 | (propagation = Propagation.REQUIRED) |
和上面类似,但是如果此时直接先执行了methodB,那么因为当前没有事务,所以methodB会直接以非事务的方式继续运行。
如果先执行了methodA,那么过程和上面的PROPAGATION_REQUIRED一样,B会加入A的事务。
- PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性),这个解释很清楚了,B加入A,或者B抛出异常。
不支持当前事务的情况:
- PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。字面解释的也很清楚,先执行了methodA,再执行methodB的时候,会挂起A,直接执行B的代码。
举例,代码如下:
1 | (propagation = Propagation.REQUIRED) |
那么执行的流程会是:
1 | // 开启一个事务1 |
如果方法B的代码出错了,那么只会回滚B的程序,A还是能够继续执行。如果A出错了,也只会回滚A,不会回滚B,因为A和B是两个事务,互不相关。
- PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 (很少用)
- PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。
举例,代码如下:
1 | (propagation = Propagation.REQUIRED) |
此时只能以非事务方式调用methodB,也就是直接执行B。如果你先执行A,那么在执行B的时候,因为当前存在了事务A,所以程序会抛出异常!
其他情况:
- PROPAGATION_NESTED: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。
举例,代码如下:
1 | (propagation = Propagation.REQUIRED) |
那么执行流程会是:
1 | // 开启一个事务 |
需要注意,如果执行到了A的doSomethingPost()
的时候出了异常,那么会把A和B全部回滚。而B如果出了异常,那么只会回滚B。
也就是说:嵌套事务,外层的事务如果回滚,会导致内层的事务也回滚;但是内层的事务如果回滚,仅仅是回滚自己的代码。
需要注意,面试的时候,问事务传播机制不会直接傻乎乎地问你知不知道啥啥的,而是会营造一个场景,比如:
我们现在有一段业务逻辑,方法A调用方法B,我希望的是如果说方法A出错了,此时仅仅回滚方法A,不能回滚方法B,必须得用REQUIRES_NEW,传播机制,让他们俩的事务是不同的。
方法A调用方法B,如果出错,方法B只能回滚他自己,方法A可以带着方法B一起回滚,NESTED嵌套事务。
Spring Boot如何演化得来
spring boot本身是spring这个项目发展到一定阶段之后的一个产物,
在传统中,我们使用spring框架,mybatis,spring mvc,去做一些开发,打包部署到线上的tomcat里去,tomcat启动了,他就会接收http请求,转发给spring mvc框架,在架构中的调用顺序为:controller -> service -> dao -> mybatis(sql语句)。
带了后面,在进行java web开发的时候,经常需要整合进来redis、elasticsearch、还有很多其他的一些东西,rabbitmq、zookeeper,等等,诸如此类的一些东西,会很繁杂,但是又是使用的常态。而如果没有spring boot,我们需要自己手动做很多的配置,自己去定义一些bean,流程比较繁琐。
为了解决这个问题,国外的spring开源社区,就发起了一个项目即spring boot,我们基于spring boot直接进行开发,里面还是使用spring + spring mvc + mybatis一些框架,我们可以一定程度上来简化我们之前的开发流程。
spring boot内嵌一个tomcat去直接让我们一下子就可以把写好的java web系统给启动起来,直接运行一个main方法,spring boot就直接把tomcat服务器给跑起来,把我们的代码运行起来了。
spring boot的另一个核心功能是自动装配,比如说我们可以引入mybatis,我其实主要引入一个starter依赖,他会一定程度上个自动完成mybatis的一些配置和定义,不需要我们手工去做大量的配置了,一定程度上简化我们搭建一个工程的成本。
也就是说,如果没有spring boot,如果要使用mybatis,我们要引入一些mybatis的jar包,还有mybatis依赖的一些其他的jar包,然后动手编写一些xml配置文件,然后定义一些bean,写一些sql语句,写一些dao代码,之后才可以使用mybatis去执行sql语句。
但是用了spring boot之后,只要引入一个starter,他会自动给你引入需要的一些jar包,做非常简单的、必须的一些配置,比如数据库的地址,几乎就不用你做太多的其他额外的配置了,他会自动帮你去进行一些配置,定义和生成对应的bean。生成的bean会自动注入比如你的dao里去,让你免去一些手工配置+定义bean的一些工作。
很多人意识到了spring开发的复杂性,想到了约定大于配置的思路,spring boot + spring + spirng mvc + mybatis + XXX之类的技术去进行开发,后续很多配置和定义的一些繁琐的重复性的工作就免去了,自动装配的一些功能,自动给你把一些事情干完了,不需要你去做了。
Spring Boot启动流程
- 配置Environment
- 准备Context上下文,包括执行 ApplicationContext 的后置处理、初始化 Initializer、通知Listener 处理 ContextPrepared 和 ContextLoaded 事件。
- 执行 refreshContext,也就是前面介绍过的 AbstractApplicationContext 类的 refresh 方法。
在SpringBoot中有两种上下文,一种是bootstrap,另外一种是application:
- bootstrap是应用程序的父上下文,会先于applicaton加载。bootstrap 主要用于从额外的资源来加载配置信息,还可以在本地外部配置文件中解密属性。
- bootstrap 里面的属性会优先加载,默认也不能被本地相同配置覆盖。
能画一张图说说Spring Boot的核心架构吗?
假定一个场景,就是我们已经有了一个spring的传统模式开发的系统,如图:
在使用了spring boot之后,会调用spring boot的main方法,自动启动一个内嵌的tomcat。
启动spring boot之后,你肯定会使用很多框架来完成任务,所以Tomcat运行起来之后,必然会把spring mvc核心的Servlet、Filter注册到Tomcat中。
然后spring boot会自动完成bean的装配和定义,一些Mybatis的bean会注入到mybatis的核心组件等等,最后程序访问的很多bean都是spring boot自动装配的。
使用spring boot之后的框架图如下:
Spring 中都用了哪些设计模式?
实际上Spring中用到很多设计模式,简单举三个例子:工厂模式,单例模式,代理模式。下面一个一个简单地介绍:
工厂模式:
工厂模式主要用在IOC,一个例子如下:
1 | public class MyController { |
这就是一个典型的工厂模式,我把MyService对象的创建封装在了MyServiceFactory工厂里面(一般创建的时候使用静态的方法)。之后每次我想使用它的时候,直接通过工厂把它拿出来即可。
spring ioc核心的设计模式的思想就是工厂模式,他自己就是一个大的工厂,把所有的bean实例都给放在了spring容器里(大工厂),如果你要使用bean,就找spring容器就可以了,你自己不用创建对象了。
单例模式:
spring默认对每个bean走的都是一个单例模式,确保说你的一个类在系统运行期间只有一个实例对象,只有一个bean,用到了一个单例模式的思想,保证了每个bean都是单例的。
单例模式的经典写法:双check+volatile+synchronized基本耳熟能详,这里用MyService类再次写出来一下:
1 | public class MyService { |
代理模式:
相比理解门槛更低的工厂模式和单例模式,代理模式算是稍微更有含金量的。
一般出现在你要对一些类的方法切入一些增强的代码,此时会创建一些动态代理的对象(比如称作B),让你对那些目标对象(比如称作A)的访问,A先经过动态代理对象B,让动态代理的对象B先做一些增强的代码,然后再调用你的目标对象A,这样A的行为就在原有基础上有了变化或者叫增强。
在设计模式里,就是一个代理模式的体现和运用,让动态代理的对象,去代理了你的目标对象,在这个过程中做一些增强的访问,你可以把面试突击的内容作为一个抛砖引玉的作用,去更加深入的学习一些技术
用一张图说说Spring Web MVC的核心架构
首先,这张图可以很生动地展现整个过程:
客户端发送请求-> 前端控制器 DispatcherServlet 接受客户端请求 -> 找到处理器映射 HandlerMapping 解析请求对应的 Handler-> HandlerAdapter 会根据 Handler 来调用真正的处理器开处理请求,并处理相应的业务逻辑 -> 处理器返回一个模型视图 ModelAndView -> 视图解析器进行解析 -> 返回一个视图对象->前端控制器 DispatcherServlet 渲染数据(Model)->将得到视图对象返回给用户 。
另外一种流程解析:
(1)tomcat的工作线程将请求转交给spring mvc框架的DispatcherServlet
(2)发送查找请求给处理器映射器HandlerMapping,如果能查找到内容,则返回执行链给前端控制器。
(3)前端控制器拿到返回的执行链之后,发送请求给处理适配器去执行Handler
(4)DispatcherServlet查找@Controller注解的controller,我们一般会给controller加上一个@RequestMapping的注解,标注说哪些controller用来处理哪些请求,之后处理器适配器会根据请求的uri,去定位到哪个controller来进行处理。
(5)根据@RequestMapping去查找,使用这个controller内的哪个方法来进行请求的处理,对每个方法一般也会加@RequestMapping的注解
(6)处理器(Handler,或者会被叫做controller)会直接调用对应的某个方法来进行请求处理,处理结束后返回一个ModelAndView视图给处理器适配器。
注意!我们的controller的方法会有一个返回值,以前的时候,一般来说还是走jsp、模板技术,我们会把前端页面放在后端的工程里面,返回一个页面模板的名字,spring mvc的框架使用模板技术,对html页面做一个渲染;返回一个json串,前后端分离,可能前端发送一个请求过来,我们只要返回json数据。即:处理完毕后,分为两种方式返回。一是前端页面放在后端工程时,可以返回对应的页面模板名字,然后springmcv使用模板技术进行渲染后返回。二是前后端分离以后,直接返回前端所需JSON串即可。
(8)再把渲染以后的html页面返回给浏览器去进行显示;前端负责把html页面渲染给浏览器就可以了
一张图说说Spring Cloud的核心架构
如下图所示:
Spring Cloud的部分主要在图的左边和上边。
这里只是简单说说Spring Cloud的架构,传统方法的spring boot、spring、spring mvc是让你开发那种单体架构的系统,而spring cloud是让你去开发分布式系统,让你把系统拆分为很多的子系统,子系统互相之间进行请求和调用,也可以比较方便地迁移微服务。
比较常用的组件:
zuul:网关,前面部署一个网关,所有请求发送到网关上,然后从网关往外去请求Tomcat来得到服务内容。
eureka 服务注册中心,服务可以通过Spring Boot内嵌的Tomcat启动,启动之后,会进行服务注册。这样之后再调用服务的时候会进行服务发现,发现其他服务的地址。
ribbon、feign:先通过ribbon由负载均衡随机地挑选一台机器,然后通过feign构造HTTP请求,再把这个请求发送到其他服务上。过程使用远程RPC调用。
hystrix主要用于服务之间调用的熔断、隔离、降级。
链路追踪等等其他组件,这些组件都是服务于分布式系统的
微服务和RPC
微服务
微服务特征:自动化部署,端点智能化,语言和数据的去中心化控制。
一种将一个单一应用程序开发为一组小型服务的方法,每个服务运行在自己的进程中,服务间通信采用轻量级通信机制(通常用HTTP资源API)。可通过全自动部署机制独立部署,共用一个最小型的集中式的管理。服务可用不同的语言开发,使用不同的数据存储技术。
服务化的一个好处就是,不限定服务的提供方使用什么技术选型,能够实现大公司跨团队的技术解耦。
微服务使用统一的RPC框架是正确的道路
如果没有统一的服务框架,RPC框架,各个团队的服务提供方就需要各自实现一套序列化、反序列化、网络框架、连接池、收发线程、超时处理、状态机等“业务之外”的重复技术劳动,造成整体的低效。所以,统一RPC框架把上述“业务之外”的技术劳动统一处理,是服务化首要解决的问题。
RPC职责
RPC框架就是解决远程调用消耗资源过大的问题,它能够让调用方“像调用本地函数一样调用远端的函数。
RPC框架要向调用方屏蔽各种复杂性,要向服务提供方也屏蔽各类复杂性:
(1)调用方感觉就像调用本地函数一样
(2)服务提供方感觉就像实现一个本地函数一样来实现服务
所以整个RPC框架又分为client部分与server部分,负责把整个非(1)(2)的各类复杂性屏蔽,这些复杂性就是RPC框架的职责。
再细化一些,client端又包含:序列化、反序列化、连接池管理、负载均衡、故障转移、队列管理,超时管理、异步管理等等等等职责。
server端包含:服务端组件、服务端收发包队列、io线程、工作线程、序列化反序列化、上下文管理器、超时管理、异步回调等等等等职责。
however,因为篇幅有限,这些细节不做深入展开。
RPC知识点结论
(1)RPC框架是架构微服务化的首要基础组件,它能大大降低架构微服务化的成本,提高调用方与服务提供方的研发效率,屏蔽跨进程调用函数(服务)的各类复杂细节
(2)RPC框架的职责是:让调用方感觉就像调用本地函数一样调用远端函数、让服务提供方感觉就像实现一个本地函数一样来实现服务