计算机网络面试问题集锦
1、Http和Https的区别
Http协议运行在TCP之上,明文传输,客户端与服务器端都无法验证对方的身份;Https是身披SSL(Secure Socket Layer)外壳的Http,运行于SSL上,SSL运行于TCP之上,是添加了加密和认证机制的HTTP。二者之间存在如下不同:
- 端口不同:Http与Https使用不同的连接方式,用的端口也不一样,前者是80,后者是443;
- 资源消耗:和HTTP通信相比,Https通信会由于加减密处理消耗更多的CPU和内存资源;
- 开销:Https通信需要证书,而证书一般需要向认证机构购买; Https的加密机制是一种共享密钥加密和公开密钥加密并用的混合加密机制。
2、对称加密与非对称加密
对称密钥加密是指加密和解密使用同一个密钥的方式,这种方式存在的最大问题就是密钥发送问题,即如何安全地将密钥发给对方;而非对称加密是指使用一对非对称密钥,即公钥和私钥,公钥可以随意发布,但私钥只有自己知道。发送密文的一方使用对方的公钥进行加密处理,对方接收到加密信息后,使用自己的私钥进行解密。
由于非对称加密的方式不需要发送用来解密的私钥,所以可以保证安全性;但是和对称加密比起来,它非常的慢,所以我们还是要用对称加密来传送消息,但对称加密所使用的密钥我们可以通过非对称加密的方式发送出去。
3、三次握手与四次挥手
(1). 三次握手(我要和你建立链接,你真的要和我建立链接么,我真的要和你建立链接,成功):
-
第一次握手:Client将标志位SYN置为1,随机产生一个值seq=J,并将该数据包发送给Server,Client进入SYN_SENT状态,等待Server确认。
-
第二次握手:Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。
-
第三次握手:Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。

(2). 四次挥手(我要和你断开链接;好的,断吧。我也要和你断开链接;好的,断吧):
-
第一次挥手:Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。
-
第二次挥手:Server收到FIN后,发送一个ACK给Client,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。此时TCP链接处于半关闭状态,即客户端已经没有要发送的数据了,但服务端若发送数据,则客户端仍要接收。
-
第三次挥手:Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。
-
第四次挥手:Client收到FIN后,Client进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。

4、为什么TCP链接需要三次握手,两次不可以么,为什么?
为了防止 已失效的链接请求报文突然又传送到了服务端,因而产生错误。
客户端发出的连接请求报文并未丢失,而是在某个网络节点长时间滞留了,以致延误到链接释放以后的某个时间才到达Server。这是,Server误以为这是Client发出的一个新的链接请求,于是就向客户端发送确认数据包,同意建立链接。若不采用“三次握手”,那么只要Server发出确认数据包,新的链接就建立了。由于client此时并未发出建立链接的请求,所以其不会理睬Server的确认,也不与Server通信;而这时Server一直在等待Client的请求,这样Server就白白浪费了一定的资源。若采用“三次握手”,在这种情况下,由于Server端没有收到来自客户端的确认,则就会知道Client并没有要求建立请求,就不会建立链接。
5、TCP协议如何来保证传输的可靠性
TCP提供一种面向连接的、可靠的字节流服务。其中,面向连接意味着两个使用TCP的应用(通常是一个客户和一个服务器)在彼此交换数据之前必须先建立一个TCP连接。在一个TCP连接中,仅有两方进行彼此通信;而字节流服务意味着两个应用程序通过TCP链接交换8bit字节构成的字节流,TCP不在字节流中插入记录标识符。
对于可靠性,TCP通过以下方式进行保证:
- 数据包校验:目的是检测数据在传输过程中的任何变化,若校验出包有错,则丢弃报文段并且不给出响应,这时TCP发送数据端超时后会重发数据;
- 对失序数据包重排序:既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。TCP将对失序数据进行重新排序,然后才交给应用层;
- 丢弃重复数据:对于重复数据,能够丢弃重复数据;
- 应答机制:当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒;
- 超时重发:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段;
- 流量控制:TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据,这可以防止较快主机致使较慢主机的缓冲区溢出,这就是流量控制。TCP使用的流量控制协议是可变大小的滑动窗口协议。
6、客户端不断进行请求链接会怎样?DDos(Distributed Denial of Service)攻击?
服务器端会为每个请求创建一个链接,并向其发送确认报文,然后等待客户端进行确认
1)、DDos 攻击
- 客户端向服务端发送请求链接数据包
- 服务端向客户端发送确认数据包
- 客户端不向服务端发送确认数据包,服务器一直等待来自客户端的确认
2)、DDos 预防 ( 没有彻底根治的办法,除非不使用TCP )
- 限制同时打开SYN半链接的数目
- 缩短SYN半链接的Time out 时间
- 关闭不必要的服务
7、Get与POST的区别
GET与POST是我们常用的两种HTTP Method,二者之间的区别主要包括如下五个方面:
(1). 从功能上讲,GET一般用来从服务器上获取资源,POST一般用来更新服务器上的资源;
(2). 从REST服务角度上说,GET是幂等的,即读取同一个资源,总是得到相同的数据,而POST不是幂等的,因为每次请求对资源的改变并不是相同的;进一步地,GET不会改变服务器上的资源,而POST会对服务器资源进行改变;
(3). 从请求参数形式上看,GET请求的数据会附在URL之后,即将请求数据放置在HTTP报文的 请求头 中,以?分割URL和传输数据,参数之间以&相连。特别地,如果数据是英文字母/数字,原样发送;否则,会将其编码为 application/x-www-form-urlencoded MIME 字符串(如果是空格,转换为+,如果是中文/其他字符,则直接把字符串用BASE64加密,得出如:%E4%BD%A0%E5%A5%BD,其中%XX中的XX为该符号以16进制表示的ASCII);而POST请求会把提交的数据则放置在是HTTP请求报文的 请求体 中。
(4). 就安全性而言,POST的安全性要比GET的安全性高,因为GET请求提交的数据将明文出现在URL上,而且POST请求参数则被包装到请求体中,相对更安全。
(5). 从请求的大小看,GET请求的长度受限于浏览器或服务器对URL长度的限制,允许发送的数据量比较小,而POST请求则是没有大小限制的。
1). GET请求中URL编码的意义
我们知道,在GET请求中会对URL中非西文字符进行编码,这样做的目的就是为了 避免歧义。看下面的例子,
针对“name1=value1&name2=value2”的例子,我们来谈一下数据从客户端到服务端的解析过程。首先,上述字符串在计算机中用ASCII吗表示为:
6E616D6531 3D 76616C756531 26 6E616D6532 3D 76616C756532
6E616D6531:name1
3D:=
76616C756531:value1
26:&
6E616D6532:name2
3D:=
76616C756532:value2 12345678
服务端在接收到该数据后就可以遍历该字节流,一个字节一个字节的吃,当吃到3D这字节后,服务端就知道前面吃得字节表示一个key,再往后吃,如果遇到26,说明从刚才吃的3D到26子节之间的是上一个key的value,以此类推就可以解析出客户端传过来的参数。
现在考虑这样一个问题,如果我们的参数值中就包含=或&这种特殊字符的时候该怎么办?比如,“name1=value1”,其中value1的值是“va&lu=e1”字符串,那么实际在传输过程中就会变成这样“name1=va&lu=e1”。这样,我们的本意是只有一个键值对,但是服务端却会解析成两个键值对,这样就产生了歧义。
那么,如何解决上述问题带来的歧义呢?解决的办法就是对参数进行URL编码:例如,我们对上述会产生歧义的字符进行URL编码后结果:“name1=va%26lu%3D”,这样服务端会把紧跟在“%”后的字节当成普通的字节,就是不会把它当成各个参数或键值对的分隔符。更多关于 URL编码 的内容,请参考我的博文《使用 URLDecoder 和 URLEncoder 对中文字符进行编码和解码》,此不赘述。
8、TCP与UDP的区别
TCP (Transmission Control Protocol)和UDP(User Datagram Protocol)协议属于传输层协议,它们之间的区别包括:
- TCP是面向连接的,UDP是无连接的;
- TCP是可靠的,UDP是不可靠的;
- TCP只支持点对点通信,UDP支持一对一、一对多、多对一、多对多的通信模式;
- TCP是面向字节流的,UDP是面向报文的;
- TCP有拥塞控制机制;UDP没有拥塞控制,适合媒体通信;
- TCP首部开销(20个字节)比UDP的首部开销(8个字节)要大;
9、TCP的拥塞处理
计算机网络中的带宽、交换结点中的缓存及处理机等都是网络的资源。在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就会变坏,这种情况就叫做拥塞。拥塞控制就是 防止过多的数据注入网络中,这样可以使网络中的路由器或链路不致过载。注意,**拥塞控制和流量控制不同,前者是一个全局性的过程,而后者指点对点通信量的控制。**拥塞控制的方法主要有以下四种:
1). **慢启动:**不要一开始就发送大量的数据,先探测一下网络的拥塞程度,也就是说由小到大逐渐增加拥塞窗口的大小;
2). **拥塞避免:**拥塞避免算法让拥塞窗口缓慢增长,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1,而不是加倍,这样拥塞窗口按线性规律缓慢增长。

3). **快重传:**快重传要求接收方在收到一个 失序的报文段 后就立即发出 重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。

4). **快恢复:**快重传配合使用的还有快恢复算法,当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半,但是接下去并不执行慢开始算法:因为如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh的大小,然后执行拥塞避免算法。

10、从输入网址到获得页面的过程
(1). 浏览器查询 DNS,获取域名对应的IP地址:具体过程包括浏览器搜索自身的DNS缓存、搜索操作系统的DNS缓存、读取本地的Host文件和向本地DNS服务器进行查询等。对于向本地DNS服务器进行查询,如果要查询的域名包含在本地配置区域资源中,则返回解析结果给客户机,完成域名解析(此解析具有权威性);如果要查询的域名不由本地DNS服务器区域解析,但该服务器已缓存了此网址映射关系,则调用这个IP地址映射,完成域名解析(此解析不具有权威性)。如果本地域名服务器并未缓存该网址映射关系,那么将根据其设置发起递归查询或者迭代查询;
(2). 浏览器获得域名对应的IP地址以后,浏览器向服务器请求建立链接,发起三次握手;
(3). TCP/IP链接建立起来后,浏览器向服务器发送HTTP请求;
(4). 服务器接收到这个请求,并根据路径参数映射到特定的请求处理器进行处理,并将处理结果及相应的视图返回给浏览器;
(5). 浏览器解析并渲染视图,若遇到对js文件、css文件及图片等静态资源的引用,则重复上述步骤并向服务器请求这些资源;
(6). 浏览器根据其请求到的资源、数据渲染页面,最终向用户呈现一个完整的页面。
11、Session、Cookie 与 Application
Cookie和Session都是客户端与服务器之间保持状态的解决方案,具体来说,cookie机制采用的是在客户端保持状态的方案,而session机制采用的是在服务器端保持状态的方案。
(1). Cookie及其相关API
Cookie实际上是一小段的文本信息。客户端请求服务器,如果服务器需要记录该用户状态,就使用response向客户端浏览器颁发一个Cookie,而客户端浏览器会把Cookie保存起来。当浏览器再请求该网站时,浏览器把请求的网址连同该Cookie一同提交给服务器,服务器检查该Cookie,以此来辨认用户状态。服务器还可以根据需要修改Cookie的内容。


(2). Session及其相关API
同样地,会话状态也可以保存在服务器端。客户端请求服务器,如果服务器记录该用户状态,就获取Session来保存状态,这时,如果服务器已经为此客户端创建过session,服务器就按照sessionid把这个session检索出来使用;如果客户端请求不包含sessionid,则为此客户端创建一个session并且生成一个与此session相关联的sessionid,并将这个sessionid在本次响应中返回给客户端保存。保存这个sessionid的方式可以采用 cookie机制 ,这样在交互过程中浏览器可以自动的按照规则把这个标识发挥给服务器;若浏览器禁用Cookie的话,可以通过 URL重写机制 将sessionid传回服务器。

(3). Session 与 Cookie 的对比
-
实现机制:Session的实现常常依赖于Cookie机制,通过Cookie机制回传SessionID;
-
大小限制:Cookie有大小限制并且浏览器对每个站点也有cookie的个数限制,Session没有大小限制,理论上只与服务器的内存大小有关;
-
安全性:Cookie存在安全隐患,通过拦截或本地文件找得到cookie后可以进行攻击,而Session由于保存在服务器端,相对更加安全;
-
服务器资源消耗:Session是保存在服务器端上会存在一段时间才会消失,如果session过多会增加服务器的压力。
Application(ServletContext):与一个Web应用程序相对应,为应用程序提供了一个全局的状态,所有客户都可以使用该状态。
(4). Application
Application(Java Web中的ServletContext):与一个Web应用程序相对应,为应用程序提供了一个全局的状态,所有客户都可以使用该状态。
12、SQL 注入
SQL注入就是通过把SQL命令插入到Web表单提交或输入域名或页面请求的查询字符串,最终达到欺骗服务器执行恶意的SQL命令。
1). SQL注入攻击的总体思路
(1). 寻找到SQL注入的位置 (2). 判断服务器类型和后台数据库类型 (3). 针对不通的服务器和数据库特点进行SQL注入攻击
2). SQL注入攻击实例
比如,在一个登录界面,要求输入用户名和密码,可以这样输入实现免帐号登录:
用户名: ‘or 1 = 1 --
密 码:12
用户一旦点击登录,如若没有做特殊处理,那么这个非法用户就很得意的登陆进去了。这是为什么呢?下面我们分析一下:从理论上说,后台认证程序中会有如下的SQL语句:String sql = “select * from user_table where username=’ “+userName+” ’ and password=’ “+password+” ‘”; 因此,当输入了上面的用户名和密码,上面的SQL语句变成:SELECT * FROM user_table WHERE username=’’or 1 = 1 – and password=’’。分析上述SQL语句我们知道, username=‘ or 1=1 这个语句一定会成功;然后后面加两个-,这意味着注释,它将后面的语句注释,让他们不起作用。这样,上述语句永远都能正确执行,用户轻易骗过系统,获取合法身份。
3). 应对方法
(1). 参数绑定
使用预编译手段,绑定参数是最好的防SQL注入的方法。目前许多的ORM框架及JDBC等都实现了SQL预编译和参数绑定功能,攻击者的恶意SQL会被当做SQL的参数而不是SQL命令被执行。在mybatis的mapper文件中,对于传递的参数我们一般是使用#和$来获取参数值。当使用#时,变量是占位符,就是一般我们使用javajdbc的PrepareStatement时的占位符,所有可以防止sql注入;当使用$时,变量就是直接追加在sql中,一般会有sql注入问题。
(2). 使用正则表达式过滤传入的参数
13、 XSS 攻击
XSS是一种经常出现在web应用中的计算机安全漏洞,与SQL注入一起成为web中最主流的攻击方式。XSS是指恶意攻击者利用网站没有对用户提交数据进行转义处理或者过滤不足的缺点,进而添加一些脚本代码嵌入到web页面中去,使别的用户访问都会执行相应的嵌入代码,从而盗取用户资料、利用用户身份进行某种动作或者对访问者进行病毒侵害的一种攻击方式。
1). XSS攻击的危害
- 盗取各类用户帐号,如机器登录帐号、用户网银帐号、各类管理员帐号
- 控制企业数据,包括读取、篡改、添加、删除企业敏感数据的能力
- 盗窃企业重要的具有商业价值的资料
- 非法转账
- 强制发送电子邮件
- 网站挂马
- 控制受害者机器向其它网站发起攻击
2). 原因解析
主要原因:过于信任客户端提交的数据!
解决办法:不信任任何客户端提交的数据,只要是客户端提交的数据就应该先进行相应的过滤处理然后方可进行下一步的操作。
进一步分析细节:客户端提交的数据本来就是应用所需要的,但是恶意攻击者利用网站对客户端提交数据的信任,在数据中插入一些符号以及javascript代码,那么这些数据将会成为应用代码中的一部分了,那么攻击者就可以肆无忌惮地展开攻击啦,因此我们绝不可以信任任何客户端提交的数据!!!
3). XSS 攻击分类
(1). 反射性XSS攻击 (非持久性XSS攻击)
漏洞产生的原因是攻击者注入的数据反映在响应中。一个典型的非持久性XSS攻击包含一个带XSS攻击向量的链接(即每次攻击需要用户的点击),例如,正常发送消息:
http://www.test.com/message.php?send=Hello,World!1
接收者将会接收信息并显示Hello,World;但是,非正常发送消息:
http://www.test.com/message.php?send=<script>alert(‘foolish!’)</script>!1
接收者接收消息显示的时候将会弹出警告窗口!
(2). 持久性XSS攻击 (留言板场景)
XSS攻击向量(一般指XSS攻击代码)存储在网站数据库,当一个页面被用户打开的时候执行。也就是说,每当用户使用浏览器打开指定页面时,脚本便执行。与非持久性XSS攻击相比,持久性XSS攻击危害性更大。从名字就可以了解到,持久性XSS攻击就是将攻击代码存入数据库中,然后客户端打开时就执行这些攻击代码。
例如,留言板表单中的表单域:
1
<input type=“text” name=“content” value=“这里是用户填写的数据”>1
正常操作流程是:用户是提交相应留言信息 —— 将数据存储到数据库 —— 其他用户访问留言板,应用去数据并显示;而非正常操作流程是攻击者在value填写:
<script>alert(‘foolish!’);</script> <!--或者html其他标签(破坏样式。。。)、一段攻击型代码-->1
并将数据提交、存储到数据库中;当其他用户取出数据显示的时候,将会执行这些攻击性代码。
4). 修复漏洞方针
漏洞产生的根本原因是 太相信用户提交的数据,对用户所提交的数据过滤不足所导致的,因此解决方案也应该从这个方面入手,具体方案包括:
-
将重要的cookie标记为http only, 这样的话Javascript 中的document.cookie语句就不能 获取到cookie了(如果在cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击);
-
表单数据规定值的类型,例如:年龄应为只能为int、name只能为字母数字组合。。。。
-
对数据进行Html Encode 处理
-
过滤或移除特殊的Html标签,例如: <script>, <iframe> , < for <, > for>, " for
-
过滤JavaScript 事件的标签,例如 “οnclick=”, “onfocus” 等等。
需要注意的是,在有些应用中是允许html标签出现的,甚至是javascript代码出现。因此,我们在过滤数据的时候需要仔细分析哪些数据是有特殊要求(例如输出需要html代码、javascript代码拼接、或者此表单直接允许使用等等),然后区别处理!
14、OSI网络体系结构与TCP/IP协议模型
为了更好地了解计算机网络体系结构,笔者以两篇博客的篇幅来介绍这个计算机网络中最为重要的知识点,具体见《计算机网络体系结构综述(上)》 和 《计算机网络体系结构综述(下)》。下面只做简要的总结。
在《计算机网络体系结构综述(下)》一文中,我们知道TCP/IP与OSI最大的不同在于:OSI是一个理论上的网络通信模型,而TCP/IP则是实际上的网络通信标准。但是,它们的初衷是一样的,都是为了使得两台计算机能够像两个知心朋友那样能够互相准确理解对方的意思并做出优雅的回应。现在,我们对OSI七层模型的各层进行简要的介绍:

1). 物理层
参考模型的最低层,也是OSI模型的第一层,实现了相邻计算机节点之间比特流的透明传送,并尽可能地屏蔽掉具体传输介质和物理设备的差异,使其上层(数据链路层)不必关心网络的具体传输介质。
2). 数据链路层(data link layer)
接收来自物理层的位流形式的数据,并封装成帧,传送到上一层;同样,也将来自上层的数据帧,拆装为位流形式的数据转发到物理层。这一层在物理层提供的比特流的基础上,通过差错控制、流量控制方法,使有差错的物理线路变为无差错的数据链路,即提供可靠的通过物理介质传输数据的方法。
3). 网络层
将网络地址翻译成对应的物理地址,并通过路由选择算法为分组通过通信子网选择最适当的路径。

4). 传输层(transport layer)
在源端与目的端之间提供可靠的透明数据传输,使上层服务用户不必关系通信子网的实现细节。在协议栈中,传输层位于网络层之上,传输层协议为不同主机上运行的进程提供逻辑通信,而网络层协议为不同主机提供逻辑通信,如下图所示。

实际上,网络层可以看作是传输层的一部分,其为传输层提供服务。但对于终端系统而言,网络层对它们而言是透明的,它们知道传输层的存在,也就是说,在逻辑上它们认为是传输层为它们提供了端对端的通信,这也是分层思想的妙处。
5). 会话层(Session Layer)
会话层是OSI模型的第五层,是用户应用程序和网络之间的接口,负责在网络中的两节点之间建立、维持和终止通信。
6). 表示层(Presentation Layer):数据的编码,压缩和解压缩,数据的加密和解密
表示层是OSI模型的第六层,它对来自应用层的命令和数据进行解释,以确保一个系统的应用层所发送的信息可以被另一个系统的应用层读取。
7). 应用层(Application layer):为用户的应用进程提供网络通信服务
15、TCP和UDP分别对应的常见应用层协议
1). TCP对应的应用层协议
- FTP:定义了文件传输协议,使用21端口。常说某某计算机开了FTP服务便是启动了文件传输服务。下载文件,上传主页,都要用到FTP服务。
- Telnet:它是一种用于远程登陆的端口,用户可以以自己的身份远程连接到计算机上,通过这种端口可以提供一种基于DOS模式下的通信服务。如以前的BBS是-纯字符界面的,支持BBS的服务器将23端口打开,对外提供服务。
- SMTP:定义了简单邮件传送协议,现在很多邮件服务器都用的是这个协议,用于发送邮件。如常见的免费邮件服务中用的就是这个邮件服务端口,所以在电子邮件设置-中常看到有这么SMTP端口设置这个栏,服务器开放的是25号端口。
- POP3:它是和SMTP对应,POP3用于接收邮件。通常情况下,POP3协议所用的是110端口。也是说,只要你有相应的使用POP3协议的程序(例如Fo-xmail或Outlook),就可以不以Web方式登陆进邮箱界面,直接用邮件程序就可以收到邮件(如是163邮箱就没有必要先进入网易网站,再进入自己的邮-箱来收信)。
- HTTP:从Web服务器传输超文本到本地浏览器的传送协议。
2). UDP对应的应用层协议
- DNS:用于域名解析服务,将域名地址转换为IP地址。DNS用的是53号端口。
- SNMP:简单网络管理协议,使用161号端口,是用来管理网络设备的。由于网络设备很多,无连接的服务就体现出其优势。
- TFTP(Trival File Transfer Protocal):简单文件传输协议,该协议在熟知端口69上使用UDP服务。
3). 图示

16、网络层的ARP协议工作原理
**网络层的ARP协议完成了IP地址与物理地址的映射。**首先,每台主机都会在自己的ARP缓冲区中建立一个ARP列表,以表示IP地址和MAC地址的对应关系。当源主机需要将一个数据包要发送到目的主机时,会首先检查自己ARP列表中是否存在该IP地址对应的MAC地址:如果有,就直接将数据包发送到这个MAC地址;如果没有,就向本地网段发起一个ARP请求的广播包,查询此目的主机对应的MAC地址。此ARP请求数据包里包括源主机的IP地址、硬件地址、以及目的主机的IP地址。网络中所有的主机收到这个ARP请求后,会检查数据包中的目的IP是否和自己的IP地址一致。如果不相同就忽略此数据包;如果相同,该主机首先将发送端的MAC地址和IP地址添加到自己的ARP列表中,如果ARP表中已经存在该IP的信息,则将其覆盖,然后给源主机发送一个ARP响应数据包,告诉对方自己是它需要查找的MAC地址;源主机收到这个ARP响应数据包后,将得到的目的主机的IP地址和MAC地址添加到自己的ARP列表中,并利用此信息开始数据的传输。如果源主机一直没有收到ARP响应数据包,表示ARP查询失败。
17、IP地址的分类
IP地址是指互联网协议地址,是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。IP地址编址方案将IP地址空间划分为A、B、C、D、E五类,其中A、B、C是基本类,D、E类作为多播和保留使用,为特殊地址。
每个IP地址包括两个标识码(ID),即网络ID和主机ID。同一个物理网络上的所有主机都使用同一个网络ID,网络上的一个主机(包括网络上工作站,服务器和路由器等)有一个主机ID与其对应。A~E类地址的特点如下:
- A类地址:以0开头,第一个字节范围:0~127;
- B类地址:以10开头,第一个字节范围:128~191;
- C类地址:以110开头,第一个字节范围:192~223;
- D类地址:以1110开头,第一个字节范围为224~239;
- E类地址:以1111开头,保留地址
1). A类地址:1字节的网络地址 + 3字节主机地址,网络地址的最高位必须是“0”
一个A类IP地址是指, 在IP地址的四段号码中,第一段号码为网络号码,剩下的三段号码为本地计算机的号码。如果用二进制表示IP地址的话,A类IP地址就由1字节的网络地址和3字节主机地址组成,网络地址的最高位必须是“0”。A类IP地址中网络的标识长度为8位,主机标识的长度为24位,A类网络地址数量较少,有126个网络,每个网络可以容纳主机数达1600多万台。
A类IP地址的地址范围1.0.0.0到127.255.255.255(二进制表示为:00000001 00000000 00000000 00000000 - 01111110 11111111 11111111 11111111),最后一个是广播地址。A类IP地址的子网掩码为255.0.0.0,每个网络支持的最大主机数为256的3次方-2=16777214台。
2). B类地址: 2字节的网络地址 + 2字节主机地址,网络地址的最高位必须是“10”
一个B类IP地址是指,在IP地址的四段号码中,前两段号码为网络号码。如果用二进制表示IP地址的话,B类IP地址就由2字节的网络地址和2字节主机地址组成,网络地址的最高位必须是“10”。B类IP地址中网络的标识长度为16位,主机标识的长度为16位,B类网络地址适用于中等规模的网络,有16384个网络,每个网络所能容纳的计算机数为6万多台。
B类IP地址地址范围128.0.0.0-191.255.255.255(二进制表示为:10000000 00000000 00000000 00000000—-10111111 11111111 11111111 11111111),最后一个是广播地址。B类IP地址的子网掩码为255.255.0.0,每个网络支持的最大主机数为256的2次方-2=65534台。
3). C类地址: 3字节的网络地址 + 1字节主机地址,网络地址的最高位必须是“110”
一个C类IP地址是指,在IP地址的四段号码中,前三段号码为网络号码,剩下的一段号码为本地计算机的号码。如果用二进制表示IP地址的话,C类IP地址就由3字节的网络地址和1字节主机地址组成,网络地址的最高位必须是“110”。C类IP地址中网络的标识长度为24位,主机标识的长度为8位,C类网络地址数量较多,有209万余个网络。适用于小规模的局域网络,每个网络最多只能包含254台计算机。
C类IP地址范围192.0.0.0-223.255.255.255(二进制表示为: 11000000 00000000 00000000 00000000 - 11011111 11111111 11111111 11111111)。C类IP地址的子网掩码为255.255.255.0,每个网络支持的最大主机数为256-2=254台。
4). D类地址:多播地址,用于1对多通信,最高位必须是“1110”
D类IP地址在历史上被叫做多播地址(multicast address),即组播地址。在以太网中,多播地址命名了一组应该在这个网络中应用接收到一个分组的站点。多播地址的最高位必须是“1110”,范围从224.0.0.0到239.255.255.255。
5). E类地址:为保留地址,最高位必须是“1111”
18、IP地址与物理地址
物理地址是数据链路层和物理层使用的地址,IP地址是网络层和以上各层使用的地址,是一种逻辑地址,其中ARP协议用于IP地址与物理地址的对应。
21、 常见状态码及原因短语
HTTP请求结构: 请求方式 + 请求URI + 协议及其版本 HTTP响应结构: 状态码 + 原因短语 + 协议及其版本
- 1×× : 请求处理中,请求已被接受,正在处理
- 2×× : 请求成功,请求被成功处理 200 OK
- 3×× : 重定向,要完成请求必须进行进一步处理 301 : 永久性转移 302 :暂时性转移 304 : 已缓存
- 4×× : 客户端错误,请求不合法 400:Bad Request,请求有语法问题 403:拒绝请求 404:客户端所访问的页面不存在
- 5×× : 服务器端错误,服务器不能处理合法请求 500 :服务器内部错误 503 : 服务不可用,稍等
操作系统面试问题集锦
1、进程和线程以及它们的区别
- 进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
- 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的 实时性,实现进程内部的并发;
- 一个程序至少有一个进程,一个进程至少有一个线程,线程依赖于进程而存在;
- 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。 更多关于 操作系统历史及进程/线程由来 的相关介绍请见笔者博文《Java 并发:并发背景》。
2、进程间的通信的几种方式
- 管道(pipe)及命名管道(named pipe):管道可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
- 信号(signal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
- 消息队列:消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
- 共享内存:可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等;
- 信号量:主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段;
- 套接字:这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。
3、线程同步的方式
- 互斥量 Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
- 信号量 Semphare:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
- 事件(信号),Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作
4、什么是死锁?死锁产生的条件?
1). 死锁的概念
在两个或者多个并发进程中,如果每个进程持有某种资源而又等待其它进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。通俗的讲,就是两个或多个进程无限期的阻塞、相互等待的一种状态。
2). 死锁产生的四个必要条件
- 互斥:至少有一个资源必须属于非共享模式,即一次只能被一个进程使用;若其他申请使用该资源,那么申请进程必须等到该资源被释放为止;
- 占有并等待:一个进程必须占有至少一个资源,并等待另一个资源,而该资源为其他进程所占有;
- 非抢占:进程不能被抢占,即资源只能被进程在完成任务后自愿释放
- 循环等待:若干进程之间形成一种头尾相接的环形等待资源关系
3). 死锁的处理基本策略和常用方法
解决死锁的基本方法主要有 预防死锁、避免死锁、检测死锁、解除死锁 、鸵鸟策略 等。
(1). 死锁预防 死锁预防的基本思想是 只要确保死锁发生的四个必要条件中至少有一个不成立,就能预防死锁的发生,具体方法包括:
- 打破互斥条件:允许进程同时访问某些资源。但是,有些资源是不能被多个进程所共享的,这是由资源本身属性所决定的,因此,这种办法通常并无实用价值。
- 打破占有并等待条件:可以实行资源预先分配策略(进程在运行前一次性向系统申请它所需要的全部资源,若所需全部资源得不到满足,则不分配任何资源,此进程暂不运行;只有当系统能满足当前进程所需的全部资源时,才一次性将所申请资源全部分配给该线程)或者只允许进程在没有占用资源时才可以申请资源(一个进程可申请一些资源并使用它们,但是在当前进程申请更多资源之前,它必须全部释放当前所占有的资源)。但是这种策略也存在一些缺点:在很多情况下,无法预知一个进程执行前所需的全部资源,因为进程是动态执行的,不可预知的;同时,会降低资源利用率,导致降低了进程的并发性。
- 打破非抢占条件:允许进程强行从占有者哪里夺取某些资源。也就是说,但一个进程占有了一部分资源,在其申请新的资源且得不到满足时,它必须释放所有占有的资源以便让其它线程使用。这种预防死锁的方式实现起来困难,会降低系统性能。
- 打破循环等待条件:实行资源有序分配策略。对所有资源排序编号,所有进程对资源的请求必须严格按资源序号递增的顺序提出,即只有占用了小号资源才能申请大号资源,这样就不回产生环路,预防死锁的发生。
(2). 死锁避免的基本思想 死锁避免的基本思想是动态地检测资源分配状态,以确保循环等待条件不成立,从而确保系统处于安全状态。所谓安全状态是指:如果系统能按某个顺序为每个进程分配资源(不超过其最大值),那么系统状态是安全的,换句话说就是,如果存在一个安全序列,那么系统处于安全状态。资源分配图算法和银行家算法是两种经典的死锁避免的算法,其可以确保系统始终处于安全状态。其中,资源分配图算法应用场景为每种资源类型只有一个实例(申请边,分配边,需求边,不形成环才允许分配),而银行家算法应用于每种资源类型可以有多个实例的场景。
(3). 死锁解除
死锁解除的常用两种方法为进程终止和资源抢占。所谓进程终止是指简单地终止一个或多个进程以打破循环等待,包括两种方式:终止所有死锁进程和一次只终止一个进程直到取消死锁循环为止;所谓资源抢占是指从一个或多个死锁进程那里抢占一个或多个资源,此时必须考虑三个问题:
(I). 选择一个牺牲品 (II). 回滚:回滚到安全状态 (III). 饥饿(在代价因素中加上回滚次数,回滚的越多则越不可能继续被作为牺牲品,避免一个进程总是被回滚)
5、进程有哪几种状态?
-
就绪状态:进程已获得除处理机以外的所需资源,等待分配处理机资源;
-
运行状态:占用处理机资源运行,处于此状态的进程数小于等于CPU数;
-
阻塞状态: 进程等待某种条件,在条件满足之前无法执行;

6、线程有几种状态?
在 Java虚拟机 中,线程从最初的创建到最终的消亡,要经历若干个状态:创建(new)、就绪(runnable/start)、运行(running)、阻塞(blocked)、等待(waiting)、时间等待(time waiting) 和 消亡(dead/terminated)。在给定的时间点上,一个线程只能处于一种状态,各状态的含义如下图所示:

线程各状态之间的转换如下:

更多关于 线程状态及其转化 的相关叙述,请见笔者博文《 Java 并发:Thread 类深度解析》。
7、分页和分段有什么区别(内存管理)?
段式存储管理是一种符合用户视角的内存分配管理方案。在段式存储管理中,将程序的地址空间划分为若干段(segment),如代码段,数据段,堆栈段;这样每个进程有一个二维地址空间,相互独立,互不干扰。段式管理的优点是:没有内碎片(因为段大小可变,改变段大小来消除内碎片)。但段换入换出时,会产生外碎片(比如4k的段换5k的段,会产生1k的外碎片)
页式存储管理方案是一种用户视角内存与物理内存相分离的内存分配管理方案。在页式存储管理中,将程序的逻辑地址划分为固定大小的页(page),而物理内存划分为同样大小的帧,程序加载时,可以将任意一页放入内存中任意一个帧,这些帧不必连续,从而实现了离散分离。页式存储管理的优点是:没有外碎片(因为页的大小固定),但会产生内碎片(一个页可能填充不满)。
*两者的不同点:*
- 目的不同:分页是由于系统管理的需要而不是用户的需要,它是信息的物理单位;分段的目的是为了能更好地满足用户的需要,它是信息的逻辑单位,它含有一组其意义相对完整的信息;
- 大小不同:页的大小固定且由系统决定,而段的长度却不固定,由其所完成的功能决定;
- 地址空间不同: 段向用户提供二维地址空间;页向用户提供的是一维地址空间;
- 信息共享:段是信息的逻辑单位,便于存储保护和信息的共享,页的保护和共享受到限制;
- 内存碎片:页式存储管理的优点是没有外碎片(因为页的大小固定),但会产生内碎片(一个页可能填充不满);而段式管理的优点是没有内碎片(因为段大小可变,改变段大小来消除内碎片)。但段换入换出时,会产生外碎片(比如4k的段换5k的段,会产生1k的外碎片)。
8、操作系统中进程调度策略有哪几种?
- FCFS(先来先服务,队列实现,非抢占的):先请求CPU的进程先分配到CPU
- SJF(最短作业优先调度算法):平均等待时间最短,但难以知道下一个CPU区间长度
- 优先级调度算法(可以是抢占的,也可以是非抢占的):优先级越高越先分配到CPU,相同优先级先到先服务,存在的主要问题是:低优先级进程无穷等待CPU,会导致无穷阻塞或饥饿;解决方案:老化
- 时间片轮转调度算法(可抢占的):队列中没有进程被分配超过一个时间片的CPU时间,除非它是唯一可运行的进程。如果进程的CPU区间超过了一个时间片,那么该进程就被抢占并放回就绪队列。
- 多级队列调度算法:将就绪队列分成多个独立的队列,每个队列都有自己的调度算法,队列之间采用固定优先级抢占调度。其中,一个进程根据自身属性被永久地分配到一个队列中。
- 多级反馈队列调度算法:与多级队列调度算法相比,其允许进程在队列之间移动:若进程使用过多CPU时间,那么它会被转移到更低的优先级队列;在较低优先级队列等待时间过长的进程会被转移到更高优先级队列,以防止饥饿发生。
9、说一说进程同步有哪几种机制
原子操作、信号量机制、自旋锁管程、会合、分布式系统
10、什么是虚拟内存?
1).内存的发展历程
没有内存抽象(单进程,除去操作系统所用的内存之外,全部给用户程序使用) —> 有内存抽象(多进程,进程独立的地址空间,交换技术(内存大小不可能容纳下所有并发执行的进程) )—> 连续内存分配(固定大小分区(多道程序的程度受限),可变分区(首次适应,最佳适应,最差适应),碎片) —> 不连续内存分配(分段,分页,段页式,虚拟内存)
2).虚拟内存
虚拟内存允许执行进程不必完全在内存中。虚拟内存的基本思想是:每个进程拥有独立的地址空间,这个空间被分为大小相等的多个块,称为页(Page),每个页都是一段连续的地址。这些页被映射到物理内存,但并不是所有的页都必须在内存中才能运行程序。当程序引用到一部分在物理内存中的地址空间时,由硬件立刻进行必要的映射;当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的命令。这样,对于进程而言,逻辑上似乎有很大的内存空间,实际上其中一部分对应物理内存上的一块(称为帧,通常页和帧大小相等),还有一些没加载在内存中的对应在硬盘上,如图5所示。 注意,请求分页系统、请求分段系统和请求段页式系统都是针对虚拟内存的,通过请求实现内存与外存的信息置换。

由图5可以看出,虚拟内存实际上可以比物理内存大。当访问虚拟内存时,会访问MMU(内存管理单元)去匹配对应的物理地址(比如图5的0,1,2)。如果虚拟内存的页并不存在于物理内存中(如图5的3,4),会产生缺页中断,从磁盘中取得缺的页放入内存,如果内存已满,还会根据某种算法将磁盘中的页换出。
3). 页面置换算法
- FIFO先进先出算法:在操作系统中经常被用到,比如作业调度(主要实现简单,很容易想到);
- LRU(Least recently use)最近最少使用算法:根据使用时间到现在的长短来判断;
- LFU(Least frequently use)最少使用次数算法:根据使用次数来判断;
- OPT(Optimal replacement)最优置换算法:理论的最优,理论;就是要保证置换出去的是不再被使用的页,或者是在实际内存中最晚使用的算法。
4). 虚拟内存的应用与优点
虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中。当一个程序等待它的一部分读入内存时,可以把CPU交给另一个进程使用。虚拟内存的使用可以带来以下好处:
- 在内存中可以保留多个进程,系统并发度提高
- 解除了用户与内存之间的紧密约束,进程可以比内存的全部空间还大
11、颠簸
颠簸本质上是指频繁的页调度行为,具体来讲,进程发生缺页中断,这时,必须置换某一页。然而,其他所有的页都在使用,它置换一个页,但又立刻再次需要这个页。因此,会不断产生缺页中断,导致整个系统的效率急剧下降,这种现象称为颠簸(抖动)。
内存颠簸的解决策略包括:
- 如果是因为页面替换策略失误,可以修改替换算法来解决这个问题;
- 如果是因为运行的程序太多,造成程序无法同时将所有频繁访问的页面调入内存,则要降低多道程序的数量;
- 否则,还剩下两个办法:终止该进程或增加物理内存容量。
12、局部性原理
(1). 时间上的局部性:最近被访问的页在不久的将来还会被访问;
(2). 空间上的局部性:内存中被访问的页周围的页也很可能被访问。
数据库面试问题集锦
1、数据库范式
- 第一范式:列不可分,eg:【联系人】(姓名,性别,电话),一个联系人有家庭电话和公司电话,那么这种表结构设计就没有达到 1NF;
- 第二范式:有主键,保证完全依赖。eg:订单明细表【OrderDetail】(OrderID,ProductID,UnitPrice,Discount,Quantity,ProductName),Discount(折扣),Quantity(数量)完全依赖(取决)于主键(OderID,ProductID),而 UnitPrice,ProductName 只依赖于 ProductID,不符合2NF;
- 第三范式:无传递依赖(非主键列 A 依赖于非主键列 B,非主键列 B 依赖于主键的情况),eg:订单表【Order】(OrderID,OrderDate,CustomerID,CustomerName,CustomerAddr,CustomerCity)主键是(OrderID),CustomerName,CustomerAddr,CustomerCity 直接依赖的是 CustomerID(非主键列),而不是直接依赖于主键,它是通过传递才依赖于主键,所以不符合 3NF。
2、数据库索引
**索引是对数据库表中一个或多个列的值进行排序的数据结构,以协助快速查询、更新数据库表中数据。**索引的实现通常使用B_TREE及其变种。索引加速了数据访问,因为存储引擎不会再去扫描整张表得到需要的数据;相反,它从根节点开始,根节点保存了子节点的指针,存储引擎会根据指针快速寻找数据。

上图显示了一种索引方式。左边是数据库中的数据表,有col1和col2两个字段,一共有15条记录;右边是以col2列为索引列的B_TREE索引,每个节点包含索引的键值和对应数据表地址的指针,这样就可以都过B_TREE在 O(logn) 的时间复杂度内获取相应的数据,这样明显地加快了检索的速度。
1). 索引的底层实现原理和优化
在数据结构中,我们最为常见的搜索结构就是二叉搜索树和AVL树(高度平衡的二叉搜索树,为了提高二叉搜索树的效率,减少树的平均搜索长度)了。然而,无论二叉搜索树还是AVL树,当数据量比较大时,都会由于树的深度过大而造成I/O读写过于频繁,进而导致查询效率低下,因此对于索引而言,多叉树结构成为不二选择。特别地,B-Tree的各种操作能使B树保持较低的高度,从而保证高效的查找效率。
(1). B-Tree(平衡多路查找树)
B_TREE是一种平衡多路查找树,是一种动态查找效率很高的树形结构。B_TREE中所有结点的孩子结点的最大值称为B_TREE的阶,B_TREE的阶通常用m表示,简称为m叉树。一般来说,应该是m>=3。一颗m阶的B_TREE或是一颗空树,或者是满足下列条件的m叉树:
-
树中每个结点最多有m个孩子结点;
-
若根结点不是叶子节点,则根结点至少有2个孩子结点;
-
除根结点外,其它结点至少有(m/2的上界)个孩子结点;
-
结点的结构如下图所示,其中,n为结点中关键字个数,(m/2的上界)-1 <= n <= m-1;di(1<=i<=n)为该结点的n个关键字值的第i个,且di< d(i+1);ci(0<=i<=n)为该结点孩子结点的指针,且ci所指向的节点的关键字均大于或等于di且小于d(i+1);

-
所有的叶结点都在同一层上,并且不带信息(可以看作是外部结点或查找失败的结点,实际上这些结点不存在,指向这些结点的指针为空)。
下图是一棵4阶B_TREE,4叉树结点的孩子结点的个数范围[2,4]。其中,有2个结点有4个孩子结点,有1个结点有3个孩子结点,有5个结点有2个孩子结点。

B_TREE的查找类似二叉排序树的查找,所不同的是B-树每个结点上是多关键码的有序表,在到达某个结点时,先在有序表中查找,若找到,则查找成功;否则,到按照对应的指针信息指向的子树中去查找,当到达叶子结点时,则说明树中没有对应的关键码。由于B_TREE的高检索效率,B-树主要应用在文件系统和数据库中,对于存储在硬盘上的大型数据库文件,可以极大程度减少访问硬盘次数,大幅度提高数据检索效率。
(2). B+Tree : InnoDB存储引擎的索引实现
B+Tree是应文件系统所需而产生的一种B_TREE树的变形树。一棵m阶的B+树和m阶的B_TREE的差异在于以下三点:
- n 棵子树的结点中含有n个关键码;
- 所有的叶子结点中包含了全部关键码的信息,及指向含有这些关键码记录的指针,且叶子结点本身依关键码的大小自小而大的顺序链接;
- 非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键码。
下图为一棵3阶的B+树。通常在B+树上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点。因此可以对B+树进行两种查找运算:一种是从最小关键字起顺序查找,另一种是从根节点开始,进行随机查找。 在B+树上进行随机查找、插入和删除的过程基本上与B-树类似。只是在查找时,若非终端结点上的关键码等于给定值,并不终止,而是继续向下直到叶子结点。因此,对于B+树,不管查找成功与否,每次查找都是走了一条从根到叶子结点的路径。

(3). 为什么说B+-tree比B 树更适合实际应用中操作系统的文件索引和数据库索引?
- B+tree的磁盘读写代价更低:B+tree的内部结点并没有指向关键字具体信息的指针(红色部分),因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多,相对来说IO读写次数也就降低了;
- B+tree的查询效率更加稳定:由于内部结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引,所以,任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当;
- **数据库索引采用B+树而不是B树的主要原因:**B+树只要遍历叶子节点就可以实现整棵树的遍历,而且在数据库中基于范围的查询是非常频繁的,而B树只能中序遍历所有节点,效率太低。
(4). 文件索引和数据库索引为什么使用B+树?
文件与数据库都是需要较大的存储,也就是说,它们都不可能全部存储在内存中,故需要存储到磁盘上。而所谓索引,则为了数据的快速定位与查找,那么索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数,因此B+树相比B树更为合适。数据库系统巧妙利用了局部性原理与磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入,而红黑树这种结构,高度明显要深的多,并且由于逻辑上很近的节点(父子)物理上可能很远,无法利用局部性。最重要的是,B+树还有一个最大的好处:方便扫库。B树必须用中序遍历的方法按序扫库,而B+树直接从叶子结点挨个扫一遍就完了,B+树支持range-query非常方便,而B树不支持,这是数据库选用B+树的最主要原因。
2). 索引的优点
- 大大加快数据的检索速度,这也是创建索引的最主要的原因;
- 加速表和表之间的连接;
- 在使用分组和排序子句进行数据检索时,同样可以显著减少查询中分组和排序的时间;
- 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性;
3). 什么情况下设置了索引但无法使用?
- 以“%(表示任意0个或多个字符)”开头的LIKE语句,模糊匹配;
- OR语句前后没有同时使用索引;
- 数据类型出现隐式转化(如varchar不加单引号的话可能会自动转换为int型);
- 对于多列索引,必须满足 最左匹配原则 (eg:多列索引col1、col2和col3,则 索引生效的情形包括 col1或col1,col2或col1,col2,col3)。
4). 什么样的字段适合创建索引?
- 经常作查询选择的字段
- 经常作表连接的字段
- 经常出现在order by, group by, distinct 后面的字段
5). 创建索引时需要注意什么?
- 非空字段:应该指定列为NOT NULL,除非你想存储NULL。在mysql中,含有空值的列很难进行查询优化,因为它们使得索引、索引的统计信息以及比较运算更加复杂。你应该用0、一个特殊的值或者一个空串代替空值;
- 取值离散大的字段:(变量各个取值之间的差异程度)的列放到联合索引的前面,可以通过count()函数查看字段的差异值,返回值越大说明字段的唯一值越多字段的离散程度高;
- 索引字段越小越好:数据库的数据存储以页为单位一页存储的数据越多一次IO操作获取的数据越大效率越高。
6). 索引的缺点
- 时间方面:创建索引和维护索引要耗费时间,具体地,当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,这样就降低了数据的维护速度;
- 空间方面:索引需要占物理空间。
7). 索引的分类
- 普通索引和唯一性索引:索引列的值的唯一性
- 单个索引和复合索引:索引列所包含的列数
- 聚簇索引与非聚簇索引:聚簇索引按照数据的物理存储进行划分的。对于一堆记录来说,使用聚集索引就是对这堆记录进行堆划分,即主要描述的是物理上的存储。正是因为这种划分方法,导致聚簇索引必须是唯一的。聚集索引可以帮助把很大的范围,迅速减小范围。但是查找该记录,就要从这个小范围中Scan了;而非聚集索引是把一个很大的范围,转换成一个小的地图,然后你需要在这个小地图中找你要寻找的信息的位置,最后通过这个位置,再去找你所需要的记录。
8). 主键、自增主键、主键索引与唯一索引概念区别
主键:指字段 唯一、不为空值 的列;
主键索引:指的就是主键,主键是索引的一种,是唯一索引的特殊类型。创建主键的时候,数据库默认会为主键创建一个唯一索引;
自增主键:字段类型为数字、自增、并且是主键;
唯一索引:索引列的值必须唯一,但允许有空值。主键是唯一索引,这样说没错;但反过来说,唯一索引也是主键就错误了,因为唯一索引允许空值,主键不允许有空值,所以不能说唯一索引也是主键。
9). 主键就是聚集索引吗?主键和索引有什么区别?
**主键是一种特殊的唯一性索引,其可以是聚集索引,也可以是非聚集索引。**在SQLServer中,主键的创建必须依赖于索引,默认创建的是聚集索引,但也可以显式指定为非聚集索引。InnoDB作为MySQL存储引擎时,默认按照主键进行聚集,如果没有定义主键,InnoDB会试着使用唯一的非空索引来代替。如果没有这种索引,InnoDB就会定义隐藏的主键然后在上面进行聚集。所以,对于聚集索引来说,你创建主键的时候,自动就创建了主键的聚集索引。
3、数据库事务
事务是一个不可分割的数据库操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。
(1). 事务的特征
- 原子性(Atomicity):事务所包含的一系列数据库操作要么全部成功执行,要么全部回滚;
- 一致性(Consistency):事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态;
- 隔离性(Isolation):并发执行的事务之间不能相互影响;
- 持久性(Durability):事务一旦提交,对数据库中数据的改变是永久性的。
(2). 事务并发带来的问题
- 脏读:一个事务读取了另一个事务未提交的数据;
- 不可重复读:不可重复读的重点是修改,同样条件下两次读取结果不同,也就是说,被读取的数据可以被其它事务修改;
- 幻读:幻读的重点在于新增或者删除,同样条件下两次读出来的记录数不一样。
(3). 隔离级别
隔离级别决定了一个session中的事务可能对另一个session中的事务的影响。ANSI标准定义了4个隔离级别,MySQL的InnoDB都支持,分别是:
-
READ UNCOMMITTED:最低级别的隔离,通常又称为dirty read,它允许一个事务读取另一个事务还没commit的数据,这样可能会提高性能,但是会导致脏读问题;
-
READ COMMITTED:在一个事务中只允许对其它事务已经commit的记录可见,该隔离级别不能避免不可重复读问题;
-
REPEATABLE READ:在一个事务开始后,其他事务对数据库的修改在本事务中不可见,直到本事务commit或rollback。但是,其他事务的insert/delete操作对该事务是可见的,也就是说,该隔离级别并不能避免幻读问题。在一个事务中重复select的结果一样,除非本事务中update数据库。
-
SERIALIZABLE:最高级别的隔离,只允许事务串行执行。
MySQL默认的隔离级别是REPEATABLE READ。
(4)、mysql的事务支持
MySQL的事务支持不是绑定在MySQL服务器本身,而是与存储引擎相关:
- MyISAM:不支持事务,用于只读程序提高性能;
- InnoDB:支持ACID事务、行级锁、并发;
- Berkeley DB:支持事务。
4、实践中如何优化MySQL
实践中,MySQL的优化主要涉及SQL语句及索引的优化、数据表结构的优化、系统配置的优化和硬件的优化四个方面,如下图所示:

1)、SQL语句及索引的优化
(1). SQL语句的优化
SQL语句的优化主要包括三个问题,即如何发现有问题的SQL、如何分析SQL的执行计划以及如何优化SQL,下面将逐一解释。
a. 怎么发现有问题的SQL?(通过MySQL慢查询日志对有效率问题的SQL进行监控)
MySQL的慢查询日志是MySQL提供的一种日志记录,它用来记录在MySQL中响应时间超过阀值的语句,具体指运行时间超过long_query_time值的SQL,则会被记录到慢查询日志中。long_query_time的默认值为10,意思是运行10s以上的语句。慢查询日志的相关参数如下所示:

通过MySQL的慢查询日志,我们可以查询出执行的次数多占用的时间长的SQL、可以通过pt_query_disgest(一种mysql慢日志分析工具)分析Rows examine(MySQL执行器需要检查的行数)项去找出IO大的SQL以及发现未命中索引的SQL,对于这些SQL,都是我们优化的对象。
b. 通过explain查询和分析SQL的执行计划
使用 EXPLAIN 关键字可以知道MySQL是如何处理你的SQL语句的,以便分析查询语句或是表结构的性能瓶颈。通过explain命令可以得到表的读取顺序、数据读取操作的操作类型、哪些索引可以使用、哪些索引被实际使用、表之间的引用以及每张表有多少行被优化器查询等问题。当扩展列extra出现Using filesort和Using temporay,则往往表示SQL需要优化了。
c. SQL语句的优化
- 优化insert语句:一次插入多值;
- 应尽量避免在 where 子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描;
- 应尽量避免在 where 子句中对字段进行null值判断,否则将导致引擎放弃使用索引而进行全表扫描;
- 优化嵌套查询:子查询可以被更有效率的连接(Join)替代;
- 很多时候用 exists 代替 in 是一个好的选择。
1)、索引优化
建议在经常作查询选择的字段、经常作表连接的字段以及经常出现在order by、group by、distinct 后面的字段中建立索引。但必须注意以下几种可能会引起索引失效的情形:
- 以“%(表示任意0个或多个字符)”开头的LIKE语句,模糊匹配;
- OR语句前后没有同时使用索引;
- 数据类型出现隐式转化(如varchar不加单引号的话可能会自动转换为int型);
- 对于多列索引,必须满足最左匹配原则(eg,多列索引col1、col2和col3,则 索引生效的情形包括col1或col1,col2或col1,col2,col3)。
2). 数据库表结构的优化
数据库表结构的优化包括选择合适数据类型、表的范式的优化、表的垂直拆分和表的水平拆分等手段。
(1). 选择合适数据类型
- 使用较小的数据类型解决问题;
- 使用简单的数据类型(mysql处理int要比varchar容易);
- 尽可能的使用not null 定义字段;
- 尽量避免使用text类型,非用不可时最好考虑分表;
(2). 表的范式的优化
一般情况下,表的设计应该遵循三大范式。
(3). 表的垂直拆分
把含有多个列的表拆分成多个表,解决表宽度问题,具体包括以下几种拆分手段:
- 把不常用的字段单独放在同一个表中;
- 把大字段独立放入一个表中;
- 把经常使用的字段放在一起; 这样做的好处是非常明显的,具体包括:拆分后业务清晰,拆分规则明确、系统之间整合或扩展容易、数据维护简单。
(4). 表的水平拆分
表的水平拆分用于解决数据表中数据过大的问题,水平拆分每一个表的结构都是完全一致的。一般地,将数据平分到N张表中的常用方法包括以下两种:
- 对ID进行hash运算,如果要拆分成5个表,mod(id,5)取出0~4个值;
- 针对不同的hashID将数据存入不同的表中;
表的水平拆分会带来一些问题和挑战,包括跨分区表的数据查询、统计及后台报表的操作等问题,但也带来了一些切实的好处:
- 表分割后可以降低在查询时需要读的数据和索引的页数,同时也降低了索引的层数,提高查询速度;
- 表中的数据本来就有独立性,例如表中分别记录各个地区的数据或不同时期的数据,特别是有些数据常用,而另外一些数据不常用。
- 需要把数据存放到多个数据库中,提高系统的总体可用性(分库,鸡蛋不能放在同一个篮子里)。
3). 系统配置的优化
- 操作系统配置的优化:增加TCP支持的队列数
- mysql配置文件优化:Innodb缓存池设置(innodb_buffer_pool_size,推荐总内存的75%)和缓存池的个数(innodb_buffer_pool_instances)
4). 硬件的优化
- CPU:核心数多并且主频高的
- 内存:增大内存
- 磁盘配置和选择:磁盘性能
5、NOSQL数据库 —— Redis
Redis是一款基于内存的且支持持久化、高性能的Key-Value NoSQL 数据库,其支持丰富数据类型(string,list,set,sorted set,hash),常被用作缓存的解决方案。Redis具有以下显著特点:
- 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
- 支持丰富数据类型,支持string,list,set,sorted set,hash;
- 支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行;
- 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除。
Redis作查询缓存需要注意考虑以下几个问题,包括防止脏读、序列化查询结果、为查询结果生成一个标识和怎么使用四个问题,具体如下:
(1). 防止脏读
对一张表的查询结果放在一个哈希结构里,当对这个表进行修改、删除或者更新时,删除该哈希结构。对这张表所有的操作方法,使用注解进行标记,例如:
Hash KEY(表名) k(查询结果标识) : v() k:v k:v k:v k:v1
// 表示该方法需要执行 (缓存是否命中 ? 返回缓存并阻止方法调用 : 执行方法并缓存结果)的缓存逻辑
@RedisCache(type = JobPostModel.class)
JobPostModel selectByPrimaryKey(Integer id);
// 表示该方法需要执行清除缓存逻辑
@RedisEvict(type = JobPostModel.class)
int deleteByPrimaryKey(Integer id);1234567
我们缓存了查询结果,那么一旦数据库中的数据发生变化,缓存的结果就不可用了。为了实现这一保证,可以在执行相关表的更新查询(update,delete,insert)查询前,让相关的缓存过期。这样下一次查询时程序就会重新从数据库中读取新数据缓存到redis中。那么问题来了,在执行一条insert前我怎么知道应该让哪些缓存过期呢?对于Redis,我们可以使用Hash结构,让一张表对应一个Hash,所有在这张表上的查询都保存到该Hash下。这样当表数据发生变动时,直接让Set过期即可。我们可以自定义一个注解,在数据库查询方法上通过注解的属性注明这个操作与哪些表相关,这样在执行过期操作时,就能直接从注解中得知应该让哪些Set过期了。
(2). 序列化查询结果
利用JDK自带的ObjectInputStream/ObjectOutputStream将查询结果序列化成字节序列,即需要考虑Redis的实际存储问题。
(3). 为查询结果生成一个标识
被调用的方法所在的类名,被调用的方法的方法名,该方法的参数三者共同标识一条查询结果。也就是说,如果两次查询调用的类名、方法名和参数值相同,我们就可以确定这两次查询结果一定是相同的(在数据没有变动的前提下)。因此,我们可以将这三个元素组合成一个字符串做为key,就解决了标识问题。
(4). 以 AOP 方式使用Redis
- 方法被调用之前,根据类名、方法名和参数值生成Key;
- 通过Key向Redis发起查询;
- 如果缓存命中,则将缓存结果反序列化作为方法调用的返回值 ,并将其直接返回;
- 如果缓存未命中,则继续向数据库中查询,并将查询结果序列化存入redis中,同时将查询结果返回。
例如,插入删除缓存逻辑如下:
/**
* 在方法调用前清除缓存,然后调用业务方法
* @param jp
* @return
* @throws Throwable
*/
@Around("execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.insert*(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.update*(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.delete*(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.increase*(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.decrease*(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.complaint(..))" +
"|| execution(* com.fh.taolijie.dao.mapper.JobPostModelMapper.set*(..))")
public Object evictCache(ProceedingJoinPoint jp) throws Throwable {}1234567891011121314
6、什么是存储过程?有哪些优缺点?
存储过程是事先经过编译并存储在数据库中的一段SQL语句的集合。进一步地说,存储过程是由一些T-SQL语句组成的代码块,这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。存储过程具有以下特点:
- 存储过程只在创建时进行编译,以后每次执行存储过程都不需再重新编译,而一般 SQL 语句每执行一次就编译一次,所以使用存储过程可提高数据库执行效率;
- 当SQL语句有变动时,可以只修改数据库中的存储过程而不必修改代码;
- 减少网络传输,在客户端调用一个存储过程当然比执行一串SQL传输的数据量要小;
- 通过存储过程能够使没有权限的用户在控制之下间接地存取数据库,从而确保数据的安全。
7、简单说一说drop、delete与truncate的区别
SQL中的drop、delete、truncate都表示删除,但是三者有一些差别:
-
Delete用来删除表的全部或者一部分数据行,执行delete之后,用户需要提交(commmit)或者回滚(rollback)来执行删除或者撤销删除, delete命令会触发这个表上所有的delete触发器;
-
Truncate删除表中的所有数据,这个操作不能回滚,也不会触发这个表上的触发器,TRUNCATE比delete更快,占用的空间更小;
-
Drop命令从数据库中删除表,所有的数据行,索引和权限也会被删除,所有的DML触发器也不会被触发,这个命令也不能回滚。
因此,在不再需要一张表的时候,用drop;在想删除部分数据行时候,用delete;在保留表而删除所有数据的时候用truncate。
8、 什么叫视图?游标是什么?
视图是一种虚拟的表,通常是有一个表或者多个表的行或列的子集,具有和物理表相同的功能,可以对视图进行增,删,改,查等操作。特别地,对视图的修改不影响基本表。相比多表查询,它使得我们获取数据更容易。
游标是对查询出来的结果集作为一个单元来有效的处理。游标可以定在该单元中的特定行,从结果集的当前行检索一行或多行。可以对结果集当前行做修改。一般不使用游标,但是需要逐条处理数据的时候,游标显得十分重要。
在操作mysql的时候,我们知道MySQL检索操作返回一组称为结果集的行。这组返回的行都是与 SQL语句相匹配的行(零行或多行)。使用简单的 SELECT语句,例如,没有办法得到第一行、下一行或前 10行,也不存在每次一行地处理所有行的简单方法(相对于成批地处理它们)。有时,需要在检索出来的行中前进或后退一行或多行。这就是使用游标的原因。游标(cursor)是一个存储在MySQL服务器上的数据库查询,它不是一条 SELECT语句,而是被该语句检索出来的结果集。在存储了游标之后,应用程序可以根据需要滚动或浏览其中的数据。游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。
9、什么是触发器?
触发器是与表相关的数据库对象,在满足定义条件时触发,并执行触发器中定义的语句集合。触发器的这种特性可以协助应用在数据库端确保数据库的完整性。
10、MySQL中的悲观锁与乐观锁的实现
悲观锁与乐观锁是两种常见的资源并发锁设计思路,也是并发编程中一个非常基础的概念。
(1). 悲观锁
悲观锁的特点是先获取锁,再进行业务操作,即“悲观”的认为所有的操作均会导致并发安全问题,因此要先确保获取锁成功再进行业务操作。通常来讲,在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。当数据库执行select … for update时会获取被select中的数据行的行锁,因此其他并发执行的select … for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。select for update获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用。
这里需要特别注意的是,不同的数据库对select… for update的实现和支持都是有所区别的,例如oracle支持select for update no wait,表示如果拿不到锁立刻报错,而不是等待,mysql就没有no wait这个选项。另外,mysql还有个问题是: select… for update语句执行中所有扫描过的行都会被锁上,这一点很容易造成问题。因此,如果在mysql中用悲观锁务必要确定使用了索引,而不是全表扫描。
(2). 乐观锁
乐观锁的特点先进行业务操作,只在最后实际更新数据时进行检查数据是否被更新过,若未被更新过,则更新成功;否则,失败重试。乐观锁在数据库上的实现完全是逻辑的,不需要数据库提供特殊的支持。一般的做法是在需要锁的数据上增加一个版本号或者时间戳,然后按照如下方式实现:
1. SELECT data AS old_data, version AS old_version FROM …;
2. 根据获取的数据进行业务操作,得到new_data和new_version
3. UPDATE SET data = new_data, version = new_version WHERE version = old_version
if (updated row > 0) {
// 乐观锁获取成功,操作完成
} else {
// 乐观锁获取失败,回滚并重试
}12345678
乐观锁是否在事务中其实都是无所谓的,其底层机制是这样:在数据库内部update同一行的时候是不允许并发的,即数据库每次执行一条update语句时会获取被update行的写锁,直到这一行被成功更新后才释放。因此在业务操作进行前获取需要锁的数据的当前版本号,然后实际更新数据时再次对比版本号确认与之前获取的相同,并更新版本号,即可确认这其间没有发生并发的修改。如果更新失败,即可认为老版本的数据已经被并发修改掉而不存在了,此时认为获取锁失败,需要回滚整个业务操作并可根据需要重试整个过程。
(3). 悲观锁与乐观锁的应用场景
一般情况下,读多写少更适合用乐观锁,读少写多更适合用悲观锁。乐观锁在不发生取锁失败的情况下开销比悲观锁小,但是一旦发生失败回滚开销则比较大,因此适合用在取锁失败概率比较小的场景,可以提升系统并发性能。
11、JDBC 对事务的支持
对于JDBC而言,每条单独的语句都是一个事务,即每个语句后都隐含一个commit。实际上,Connection 提供了一个auto-commit的属性来指定事务何时结束。当auto-commit为true时,当每个独立SQL操作的执行完毕,事务立即自动提交,也就是说,每个SQL操作都是一个事务;当auto-commit为false时,每个事务都必须显式调用commit方法进行提交,或者显式调用rollback方法进行回滚。auto-commit默认为true。
try {
conn.setAutoCommit(false); //将自动提交设置为false
ps.executeUpdate("修改SQL"); //执行修改操作
ps.executeQuery("查询SQL"); //执行查询操作
conn.commit(); //当两个操作成功后手动提交
} catch (Exception e) {
conn.rollback(); //一旦其中一个操作出错都将回滚,使两个操作都不成功
e.printStackTrace();
} 123456789
为了能够将多条SQL当成一个事务执行,必须首先通过Connection关闭auto-commit模式,然后通过Connection的setTransactionIsolation()方法设置事务的隔离级别,最后分别通过Connection的commit()方法和rollback()方法来提交事务和回滚事务。
12、MySQL存储引擎中的MyISAM和InnoDB区别详解
在MySQL 5.5之前,MyISAM是mysql的默认数据库引擎,其由早期的ISAM(Indexed Sequential Access Method:有索引的顺序访问方法)所改良。虽然MyISAM性能极佳,但却有一个显著的缺点: 不支持事务处理。不过,MySQL也导入了另一种数据库引擎InnoDB,以强化参考完整性与并发违规处理机制,后来就逐渐取代MyISAM。
InnoDB是MySQL的数据库引擎之一,其由Innobase oy公司所开发,2006年五月由甲骨文公司并购。与传统的ISAM、MyISAM相比,InnoDB的最大特色就是支持ACID兼容的事务功能,类似于PostgreSQL。目前InnoDB采用双轨制授权,一是GPL授权,另一是专有软件授权。具体地,MyISAM与InnoDB作为MySQL的两大存储引擎的差异主要包括:
-
存储结构:每个MyISAM在磁盘上存储成三个文件:第一个文件的名字以表的名字开始,扩展名指出文件类型。.frm文件存储表定义,数据文件的扩展名为.MYD (MYData),索引文件的扩展名是.MYI (MYIndex)。InnoDB所有的表都保存在同一个数据文件中(也可能是多个文件,或者是独立的表空间文件),InnoDB表的大小只受限于操作系统文件的大小,一般为2GB。
-
存储空间:MyISAM可被压缩,占据的存储空间较小,支持静态表、动态表、压缩表三种不同的存储格式。InnoDB需要更多的内存和存储,它会在主内存中建立其专用的缓冲池用于高速缓冲数据和索引。
-
可移植性、备份及恢复:MyISAM的数据是以文件的形式存储,所以在跨平台的数据转移中会很方便,同时在备份和恢复时也可单独针对某个表进行操作。InnoDB免费的方案可以是拷贝数据文件、备份 binlog,或者用 mysqldump,在数据量达到几十G的时候就相对痛苦了。
-
事务支持:MyISAM强调的是性能,每次查询具有原子性,其执行数度比InnoDB类型更快,但是不提供事务支持。InnoDB提供事务、外键等高级数据库功能,具有事务提交、回滚和崩溃修复能力。
-
AUTO_INCREMENT:在MyISAM中,可以和其他字段一起建立联合索引。引擎的自动增长列必须是索引,如果是组合索引,自动增长可以不是第一列,它可以根据前面几列进行排序后递增。InnoDB中必须包含只有该字段的索引,并且引擎的自动增长列必须是索引,如果是组合索引也必须是组合索引的第一列。
-
表锁差异:MyISAM只支持表级锁,用户在操作MyISAM表时,select、update、delete和insert语句都会给表自动加锁,如果加锁以后的表满足insert并发的情况下,可以在表的尾部插入新的数据。InnoDB支持事务和行级锁。行锁大幅度提高了多用户并发操作的新能,但是InnoDB的行锁,只是在WHERE的主键是有效的,非主键的WHERE都会锁全表的。
-
全文索引:MyISAM支持 FULLTEXT类型的全文索引;InnoDB不支持FULLTEXT类型的全文索引,但是innodb可以使用sphinx插件支持全文索引,并且效果更好。
-
表主键:MyISAM允许没有任何索引和主键的表存在,索引都是保存行的地址。对于InnoDB,如果没有设定主键或者非空唯一索引,就会自动生成一个6字节的主键(用户不可见),数据是主索引的一部分,附加索引保存的是主索引的值。
-
表的具体行数:MyISAM保存表的总行数,select count() from table;会直接取出出该值;而InnoDB没有保存表的总行数,如果使用select count() from table;就会遍历整个表,消耗相当大,但是在加了wehre条件后,myisam和innodb处理的方式都一样。
-
CURD操作:在MyISAM中,如果执行大量的SELECT,MyISAM是更好的选择。对于InnoDB,如果你的数据执行大量的INSERT或UPDATE,出于性能方面的考虑,应该使用InnoDB表。DELETE从性能上InnoDB更优,但DELETE FROM table时,InnoDB不会重新建立表,而是一行一行的删除,在innodb上如果要清空保存有大量数据的表,最好使用truncate table这个命令。
-
外键:MyISAM不支持外键,而InnoDB支持外键。
通过上述的分析,基本上可以考虑使用InnoDB来替代MyISAM引擎了,原因是InnoDB自身很多良好的特点,比如事务支持、存储过程、视图、行级锁、外键等等。尤其在并发很多的情况下,相信InnoDB的表现肯定要比MyISAM强很多。另外,必须需要注意的是,任何一种表都不是万能的,合适的才是最好的,才能最大的发挥MySQL的性能优势。如果是不复杂的、非关键的Web应用,还是可以继续考虑MyISAM的,这个具体情况具体考虑。
多线程面试问题集锦
1、如何停止一个线程
- 使用volatile变量终止正常运行的线程 + 抛异常法/Return法
- 组合使用interrupt方法与interruptted/isinterrupted方法终止正在运行的线程 + 抛异常法/Return法
- 使用interrupt方法终止 正在阻塞中的 线程
2、何为线程安全的类?
在线程安全性的定义中,最核心的概念就是 正确性。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
3、为什么线程通信的方法wait(), notify()和notifyAll()被定义在Object类里?
Object lock = new Object();
synchronized (lock) {
lock.wait();
...
}12345
Wait-notify机制是在获取对象锁的前提下不同线程间的通信机制。在Java中,任意对象都可以当作锁来使用,由于锁对象的任意性,所以这些通信方法需要被定义在Object类里。
4、为什么wait(), notify()和notifyAll()必须在同步方法或者同步块中被调用?
wait/notify机制是依赖于Java中Synchronized同步机制的,其目的在于确保等待线程从Wait()返回时能够感知通知线程对共享变量所作出的修改。如果不在同步范围内使用,就会抛出java.lang.IllegalMonitorStateException的异常。
5、并发三准则
- 异常不会导致死锁现象:当线程出现异常且没有捕获处理时,JVM会自动释放当前线程占用的锁,因此不会由于异常导致出现死锁现象,同时还会释放CPU;
- 锁的是对象而非引用;
- 有wait必有notify;
6、如何确保线程安全?
在Java中可以有很多方法来保证线程安全,诸如:
- 通过加锁(Lock/Synchronized)保证对临界资源的同步互斥访问;
- 使用volatile关键字,轻量级同步机制,但不保证原子性;
- 使用不变类 和 线程安全类(原子类,并发容器,同步容器等)。
7、volatile关键字在Java中有什么作用
volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,即保证了内存的可见性,除此之外还能 禁止指令重排序。此外,synchronized关键字也可以保证内存可见性。
指令重排序问题在并发环境下会导致线程安全问题,volatile关键字通过禁止指令重排序来避免这一问题。而对于Synchronized关键字,其所控制范围内的程序在执行时独占的,指令重排序问题不会对其产生任何影响,因此无论如何,其都可以保证最终的正确性。
8、ThreadLocal及其引发的内存泄露
ThreadLocal是Java中的一种线程绑定机制,可以为每一个使用该变量的线程都提供一个变量值的副本,并且每一个线程都可以独立地改变自己的副本,而不会与其它线程的副本发生冲突。
每个线程内部有一个 ThreadLocal.ThreadLocalMap 类型的成员变量 threadLocals,这个 threadLocals 存储了与该线程相关的所有 ThreadLocal 变量及其对应的值,也就是说,ThreadLocal 变量及其对应的值就是该Map中的一个 Entry,更直白地,threadLocals中每个Entry的Key是ThreadLocal 变量本身,而Value是该ThreadLocal变量对应的值。
(1). ThreadLocal可能引起的内存泄露
下面是ThreadLocalMap的部分源码,我们可以看出ThreadLocalMap里面对Key的引用是弱引用。那么,就存在这样的情况:当释放掉对threadlocal对象的强引用后,map里面的value没有被回收,但却永远不会被访问到了,因此ThreadLocal存在着内存泄露问题。
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}
...
}1234567891011121314151617181920
看下面的图示, 实线代表强引用,虚线代表弱引用。每个thread中都存在一个map,map的类型是上文提到的ThreadLocal.ThreadLocalMap,该map中的key为一个ThreadLocal实例。这个Map的确使用了弱引用,不过弱引用只是针对key,每个key都弱引用指向ThreadLocal对象。一旦把threadlocal实例置为null以后,那么将没有任何强引用指向ThreadLocal对象,因此ThreadLocal对象将会被 Java GC 回收。但是,与之关联的value却不能回收,因为存在一条从current thread连接过来的强引用。 只有当前thread结束以后, current thread就不会存在栈中,强引用断开,Current Thread、Map及value将全部被Java GC回收。

所以,得出一个结论就是:只要这个线程对象被Java GC回收,就不会出现内存泄露。但是如果只把ThreadLocal引用指向null而线程对象依然存在,那么此时Value是不会被回收的,这就发生了我们认为的内存泄露。比如,在使用线程池的时候,线程结束是不会销毁的而是会再次使用的,这种情形下就可能出现ThreadLocal内存泄露。
Java为了最小化减少内存泄露的可能性和影响,在ThreadLocal进行get、set操作时会清除线程Map里所有key为null的value。所以最怕的情况就是,ThreadLocal对象设null了,开始发生“内存泄露”,然后使用线程池,线程结束后被放回线程池中而不销毁,那么如果这个线程一直不被使用或者分配使用了又不再调用get/set方法,那么这个期间就会发生真正的内存泄露。因此,最好的做法是:在不使用该ThreadLocal对象时,及时调用该对象的remove方法去移除ThreadLocal.ThreadLocalMap中的对应Entry。
9、什么是死锁(Deadlock)?如何分析和避免死锁?
死锁是指两个以上的线程永远阻塞的情况,这种情况产生至少需要两个以上的线程和两个以上的资源。
分析死锁,我们需要查看Java应用程序的线程转储。我们需要找出那些状态为BLOCKED的线程和他们等待的资源。每个资源都有一个唯一的id,用这个id我们可以找出哪些线程已经拥有了它的对象锁。下面列举了一些JDK自带的死锁检测工具:
(1). Jconsole:JDK自带的图形化界面工具,主要用于对 Java 应用程序做性能分析和调优。

(2). Jstack:JDK自带的命令行工具,主要用于线程Dump分析。

(3). VisualVM:JDK自带的图形化界面工具,主要用于对 Java 应用程序做性能分析和调优。

10、什么是Java Timer类?如何创建一个有特定时间间隔的任务?
Timer是一个调度器,可以用于安排一个任务在未来的某个特定时间执行或周期性执行。TimerTask是一个实现了Runnable接口的抽象类,我们需要去继承这个类来创建我们自己的定时任务并使用Timer去安排它的执行。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
public void run() {
System.out.println("abc");
}
}, 200000 , 1000);123456
11、什么是线程池?如何创建一个Java线程池?
一个线程池管理了一组工作线程,同时它还包括了一个用于放置等待执行的任务的队列。线程池可以避免线程的频繁创建与销毁,降低资源的消耗,提高系统的反应速度。java.util.concurrent.Executors提供了几个java.util.concurrent.Executor接口的实现用于创建线程池,其主要涉及四个角色:
-
线程池:Executor
-
工作线程:Worker线程,Worker的run()方法执行Job的run()方法
-
任务Job:Runable和Callable
-
阻塞队列:BlockingQueue

1). 线程池Executor
Executor及其实现类是用户级的线程调度器,也是对任务执行机制的抽象,其将任务的提交与任务的执行分离开来,核心实现类包括ThreadPoolExecutor(用来执行被提交的任务)和ScheduledThreadPoolExecutor(可以在给定的延迟后执行任务或者周期性执行任务)。Executor的实现继承链条为:(父接口)Executor -> (子接口)ExecutorService -> (实现类)[ ThreadPoolExecutor + ScheduledThreadPoolExecutor ]。
2). 任务Runable/Callable
Runnable(run)和Callable(call)都是对任务的抽象,但是Callable可以返回任务执行的结果或者抛出异常。
3). 任务执行状态Future
Future是对任务执行状态和结果的抽象,核心实现类是furtureTask (所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值) ;

(1). 使用Callable+Future获取执行结果
ExecutorService executor = Executors.newCachedThreadPool();
Task task = new Task();
Future<Integer> result = executor.submit(task);
System.out.println("task运行结果" + result.get());
class Task implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("子线程在进行计算");
Thread.sleep(3000);
int sum = 0;
for(int i=0;i<100;i++)
sum += i;
return sum;
}123456789101112131415
(2). 使用Callable + FutureTask获取执行结果
ExecutorService executor = Executors.newCachedThreadPool();
Task task = new Task();
FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
executor.submit(futureTask);
System.out.println("task运行结果"+futureTask.get());
class Task implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("子线程在进行计算");
Thread.sleep(3000);
int sum = 0;
for(int i=0;i<100;i++)
sum += i;
return sum;
}12345678910111213141516
4). 四种常用的线程池
(1). FixedThreadPool
用于创建使用固定线程数的ThreadPool,corePoolSize = maximumPoolSize = n(固定的含义),阻塞队列为LinkedBlockingQueue。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}12345
(2). SingleThreadExecutor
用于创建一个单线程的线程池,corePoolSize = maximumPoolSize = 1,阻塞队列为LinkedBlockingQueue。
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}123456
(3). CachedThreadPool
用于创建一个可缓存的线程池,corePoolSize = 0, maximumPoolSize = Integer.MAX_VALUE,阻塞队列为SynchronousQueue(没有容量的阻塞队列,每个插入操作必须等待另一个线程对应的移除操作,反之亦然)。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}12345
(4). ScheduledThreadPoolExecutor
用于创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, TimeUnit.NANOSECONDS,
new DelayedWorkQueue());
}1234
5). 线程池的饱和策略
当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
-
AbortPolicy:直接抛出异常,默认策略;
-
CallerRunsPolicy:用调用者所在的线程来执行任务;
-
DiscardOldestPolicy:丢弃阻塞队列中最老的任务,并执行当前任务;
-
DiscardPolicy:直接丢弃任务;
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
6). 线程池调优
- 设置最大线程数,防止线程资源耗尽;
- 使用有界队列,从而增加系统的稳定性和预警能力(饱和策略);
- 根据任务的性质设置线程池大小:CPU密集型任务(CPU个数个线程),IO密集型任务(CPU个数两倍的线程),混合型任务(拆分)。
12、CAS : *CAS自旋volatile变量,是一种很经典的用法。*
CAS,Compare and Swap即比较并交换,设计并发算法时常用到的一种技术。CAS有3个操作数,内存值V,旧的预期值A,新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS是通过unsafe类的compareAndSwap (JNI, Java Native Interface) 方法实现的,该方法包括四个参数:第一个参数是要修改的对象,第二个参数是对象中要修改变量的偏移量,第三个参数是修改之前的值,第四个参数是预想修改后的值。
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:ABA问题、循环时间长开销大和只能保证一个共享变量的原子操作。
- ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
- 不适用于竞争激烈的情形中:并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
- 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,因此可以把多个变量放在一个对象里来进行CAS操作。
13、AQS : 队列同步器
队列同步器(AbstractQueuedSynchronizer)是用来构建锁和其他同步组件的基础框架,技术是 CAS自旋Volatile变量:它使用了一个Volatile成员变量表示同步状态,通过CAS修改该变量的值,修改成功的线程表示获取到该锁;若没有修改成功,或者发现状态state已经是加锁状态,则通过一个Waiter对象封装线程,添加到等待队列中,并挂起等待被唤醒。
同步器是实现锁的关键,子类通过继承同步器并实现它的抽象方法来管理同步状态,利用同步器实现锁的语义。特别地,锁是面向锁使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。锁和同步器很好地隔离了锁的使用者与锁的实现者所需关注的领域。
一般来说,自定义同步器要么是独占方式,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
同步器的设计是基于 模板方法模式 的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:getState()、setState()以及compareAndSetState()。
AQS定义了两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
-
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它;
-
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false;
-
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false;
-
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源;
-
tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
14、Java Concurrency API中的Lock接口(Lock interface)是什么?对比同步它有什么优势?
synchronized是Java的关键字,是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问。Synchronized的语义底层是通过一个monitor对象来完成的,线程执行monitorenter/monitorexit指令完成锁的获取与释放。而Lock是一个Java接口(API如下图所示),是基于JDK层面实现的,通过这个接口可以实现同步访问,它提供了比synchronized关键字更灵活、更广泛、粒度更细的锁操作,底层是由AQS实现的。二者之间的差异总结如下:
-
实现层面:synchronized(JVM层面)、Lock(JDK层面)
-
响应中断:Lock 可以让等待锁的线程响应中断,而使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
-
立即返回:可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间,而synchronized却无法办到;
-
读写锁:Lock可以提高多个线程进行读操作的效率
-
可实现公平锁:Lock可以实现公平锁,而sychronized天生就是非公平锁
-
显式获取和释放:synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

15、Condition
Condition可以用来实现线程的分组通信与协作。以生产者/消费者问题为例,
-
wait/notify/notifyAll:在队列为空时,通知所有线程;在队列满时,通知所有线程,防止生产者通知生产者,消费者通知消费者的情形产生。
-
await/signal/signalAll:将线程分为消费者线程和生产者线程两组:在队列为空时,通知生产者线程生产;在队列满时,通知消费者线程消费。

16、什么是阻塞队列?如何使用阻塞队列来实现生产者-消费者模型?
java.util.concurrent.BlockingQueue的特性是:当队列是空的时,从队列中获取或删除元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。特别地,阻塞队列不接受空值,当你尝试向队列中添加空值的时候,它会抛出NullPointerException。另外,阻塞队列的实现都是线程安全的,所有的查询方法都是原子的并且使用了内部锁或者其他形式的并发控制。
BlockingQueue 接口是java collections框架的一部分,它主要用于实现生产者-消费者问题。特别地,SynchronousQueue是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程的对应移除操作,反之亦然。CachedThreadPool使用SynchronousQueue把主线程提交的任务传递给空闲线程执行。
17、同步容器(强一致性)
同步容器指的是 Vector、Stack、HashTable及Collections类中提供的静态工厂方法创建的类。其中,Vector实现了List接口,Vector实际上就是一个数组,和ArrayList类似,但是Vector中的方法都是synchronized方法,即进行了同步措施;Stack也是一个同步容器,它的方法也用synchronized进行了同步,它实际上是继承于Vector类;HashTable实现了Map接口,它和HashMap很相似,但是HashTable进行了同步处理,而HashMap没有。
Collections类是一个工具提供类,注意,它和Collection不同,Collection是一个顶层的接口。在Collections类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。最重要的是,在它里面提供了几个静态工厂方法来创建同步容器类,如下图所示:

18、什么是CopyOnWrite容器(弱一致性)?
CopyOnWrite容器即写时复制的容器,适用于读操作远多于修改操作的并发场景中。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器主要存在两个弱点:
- 容器对象的复制需要一定的开销,如果对象占用内存过大,可能造成频繁的YoungGC和Full GC;
- CopyOnWriteArrayList不能保证数据实时一致性,只能保证最终一致性。
19、ConcurrentHashMap (弱一致性)
**ConcurrentHashMap的弱一致性主要是为了提升效率,也是一致性与效率之间的一种权衡。**要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable和同步的HashMap一样了。ConcurrentHashMap的弱一致性主要体现在以下几方面:
-
get操作是弱一致的:get操作只能保证一定能看到已完成的put操作;

- clear操作是弱一致的:在清除完一个segments之后,正在清理下一个segments的时候,已经清理的segments可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap中是可能存在数据的。
public void clear() {
for (int i = 0; i < segments.length; ++i)
segments[i].clear();
}1234
- ConcurrentHashMap中的迭代操作是弱一致的(未遍历的内容发生变化可能会反映出来):在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。
20、happens-before
happens-before 指定了两个操作间的执行顺序:如果 A happens before B,那么Java内存模型将向程序员保证 —— A 的执行顺序排在 B 之前,并且 A 操作的结果将对 B 可见,其具体包括如下8条规则:
- 程序顺序规则:单线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 管程锁定规则:一个unlock操作先行发生于对同一个锁的lock操作;
- volatile变量规则:对一个Volatile变量的写操作先行发生于对这个变量的读操作;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的其他动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
21、锁优化技术
锁优化技术的目的在于线程之间更高效的共享数据,解决竞争问题,更好提高程序执行效率。
- 自旋锁(上下文切换代价大):互斥锁 -> 阻塞 –> 释放CPU,线程上下文切换代价较大 + 共享变量的锁定时间较短 == 让线程通过自旋等一会儿,自旋锁
- 锁粗化(一个大锁优于若干小锁):一系列连续操作对同一对象的反复频繁加锁/解锁会导致不必要的性能损耗,建议粗化锁 一般而言,同步范围越小越好,这样便于其他线程尽快拿到锁,但仍然存在特例。
- 偏向锁(有锁但当前情形不存在竞争):消除数据在无竞争情况下的同步原语,提高带有同步但无竞争的程序性能。
- 锁消除(有锁但不存在竞争,锁多余):JVM编译优化,将不存在数据竞争的锁消除
22、主线程等待子线程运行完毕再运行的方法
(1). Join
Thread提供了让一个线程等待另一个线程完成的方法 — join()方法。当在某个程序执行流程中调用其它线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完毕为止,在继续运行。join()方法的实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。直到join线程完成后,线程的this.notifyAll()方法会被调用。
(2). CountDownLatch
Countdown Latch允许一个或多个线程等待其他线程完成操作。CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用countDown方法时,N就会减1,await方法会阻塞当前线程,直到N变成0。这里说的N个点,可以使用N个线程,也可以是1个线程里的N个执行步骤。

(3). Sleep
用sleep方法,让主线程睡眠一段时间,当然这个睡眠时间是主观的时间,是我们自己定的,这个方法不推荐,但是在这里还是写一下,毕竟是解决方法。
Java面试问题集锦
1、Struts2和SpringMVC的区别
- 设计理念:前者为有状态的Action(均为多例),Action对象属性字段承载请求、响应,后者一般为无状态的Controller,请求直接封装到方法的参数中;
- 集中访问点不同:都属于前端控制器,用于接收请求、处理请求和生成响应,但集中访问点不同,前者为Filter,后者为Servlet;
- 请求处理粒度不同:前者一个Action对应一个请求上下文,后者一个方法对应一个请求上下文,因此更容易实现Rest;
- 拦截器机制不同:Struts2和SpringMVC的拦截器机制均是对AOP理念的应用,但Struts2的interceptor机制是通过代理机制(ActionProxy)+责任链模式实现的,而SpringMVC的interceptor机制实现比较简单,其通过循环的方式在handler处理请求前后分别调用preHandle()方法和postHandle()方法对请求和响应进行处理,与Spring AOP、责任链模式等基本无关;
- 对Ajax的支持不同:前者需要插件或者手动转化,而后者集成了对Ajax请求的处理(HttpMessageConverter);
- 与Spring的整合:前者需要插件,后者无缝整合(子容器);
- 配置/效率:后者几乎是零配置,开发效率更高。
2、Spring中IoC的理解
关键词:超级大工厂,对象控制权,解耦对象间的依赖关系
- 超级大工厂,对象控制权由调用者移交给容器,使得调用者不必关心对象的创建和管理,专注于业务逻辑开发;
- 优秀的解耦方式,解耦对象间的依赖关系,避免通过硬编码的方式耦合在一起;
- 底层实现:反射机制;
3、Spring中AOP的理解
关键词:模块化、交叉关注点、横切性质的系统级业务
- 一种新的模块化方式,专门处理系统各模块中的交叉关注点问题,将具有横切性质的系统级业务提取到切面中,与核心业务逻辑分离(解耦);
- 便于系统的扩展,符合开-闭原则;
- 动态AOP的实现,Java动态代理(接口代理)与cglib(类代理),具体由Bean后处理器生成代理;
- AOP理念实践:Spring AOP,Java Web Filter,Struts2 Interceptor, SpringMVC Interceptor,…
4、JVM 基础
1). 内存模型
(1). 程序计数器: 线程私有,CPU调度的基本单位,用于保证线程切换(程序能够在多线程环境下连续执行);
(2). 栈(服务Java方法虚拟机栈、服务Native方法的本地方法栈):线程私有,局部变量/引用,栈深度(SOF)/无法申请内存(OOM);
(3). 堆(Java代码可及的Java堆和JVM自身使用的方法区):线程共享,对象分配和回收主要区域,OOM
更多关于JVM内存模型相关内容,请参见博文《JVM 内存模型概述》。
2). 垃圾回收机制
(1). Stop-the-World
JVM由于要执行GC而停止了应用程序的执行称之为Stop-the-World,该情形会在任何一种GC算法中发生。当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成。事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有 高吞吐 、低停顿 的特点。
(2). Java堆的回收
关于Java对象的回收主要考虑以下两个问题:哪些对象可以被回收、怎么回收(有哪些回收算法以及有哪些垃圾回收器)。
-
判断对象是否可被回收:引用计数法(相互引用)、可达性算法(对象的引用链是否可达,GCRoots)
-
垃圾回收算法:标记-清除算法(内存碎片)、复制算法(垃圾回收较为频繁、对象存活率较低的新生代)、标记-整理算法(垃圾回收不频繁、对象存活率较高的老年代)、分代收集算法
-
垃圾回收器:串行收集器(新生代、老年代)、并行收集器(新生代、老年代)、并行清除收集器(并发,新生代,追求高吞吐)、CMS收集器(并发,老年代,标记-清除算法,追求低停顿)、G1垃圾收集器(整个堆,追求低停顿)
GC Roots一般包括虚拟机栈中引用的对象,本地方法栈中引用的对象,方法区中类静态属性引用的对象、方法区中常量引用的对象。
分代收集算法的基本思想是 **不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。**Minor GC 发生频率较高,不一定等 Eden区满了才触发;Major GC在老年代满时触发,对年轻代和老年代进行回收。
(3). 方法区的回收
-
对常量池的回收
-
对类型的卸载:该类的所有实例被回收,该类的ClassLoader被回收,不存在对该类的Class对象的引用
更多关于JVM垃圾回收机制相关内容,请参见博文《图解Java垃圾回收机制》。
3). OOM/SOF
- OOM for Heap:内存泄露(GC Roots的引用链,对象的生命周期超出预期)或者内存溢出(调节JVM参数 -Xms,-Xmx 等)
- OOM for Stack:一般在单线程程序中不会出现;在多线程环境下,无法申请到足够的内存去创建线程
- SOF for Stack:程序是否有深度递归
- OOM for Perm :用到像Spring等框架的时候,常常会动态地生成大量的类导致永久代不够用而导致OutOfMemoryError: PermGen Space异常(调大 -XX:MaxPermSize)
4、JVM 调优
JVM 调优的主要目标是使系统具有 高吞吐 、低停顿 的特点,其优化手段应从两方面着手:Java虚拟机 和 Java应用程序。前者指根据应用程序的设计通过虚拟机参数控制虚拟机逻辑内存分区的大小以使虚拟机的内存与程序对内存的需求相得益彰;后者指优化程序算法,降低GC负担,提高GC回收成功率。
以下是一些常用的JVM调优工具:
-
Jconsole 与 Visual VM
JConsole 与 Visual VM 都是JDK自带的 Java 性能分析器,可以从命令行或在 GUI shell 中运行,从而可以轻松使用 JConsole来监控 Java 应用程序性能和跟踪 Java 中的代码,其可以从JAVA_HOME/bin/这个目录下找到。使用 Jconsole 监测死锁示例如下:

-
Jstack
JDK自带的命令行工具,可以查看某个Java进程内的线程堆栈信息,主要用于线程Dump分析。

-
jps
jps位于jdk的bin目录下,其作用是显示当前系统的java进程情况及其id。

6、责任链(CoR)模式
目的:请求的发送者与请求的处理者解耦,便于动态的重新组织链和分配责任
角色:抽象处理者、具体处理者、客户端
UML:

传统责任链(CoR)模式的缺点在于:具体处理角色存在着共同的实现责任链结构的行为行为,即责任链的建立和指派包含在实现角色的类中,并没有抽象出来,这直接导致责任链的指派不够灵活。因此,改进的CoR模式为:使用AOP理念将责任链结构的实现用切面抽象出来,使得各个对象只关注自身必须实现的功能性需求,准确地分离出责任链模式中不同角色的共同行为,例如,

改进后责任链(CoR)模式的应用是比较广泛的,包括 Java Web Filter(链式调用),Struts2 Interceptor(Action代理)和SpringMVC等。
7、单例模式
单例模式核心在于为整个系统提供一个唯一的实例,为整个系统提供一个全局访问点。单例模式从实现上可以分为饿汉式单例和懒汉式单例两种,前者天生就是线程安全的,后者则需要考虑线程安全性,常见的线程安全的懒汉式单例的实现有内部类式和双重检查式两种。下面给出单例模式几种常见的形式:

(1). 饿汉式单例
// 饿汉式单例
public class Singleton1 {
// 指向自己实例的私有静态引用,主动创建
private static Singleton1 singleton1 = new Singleton1();
// 私有的构造方法
private Singleton1(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton1 getSingleton1(){
return singleton1;
}
}1234567891011121314
(2). 懒汉式单例
// 懒汉式单例
public class Singleton2 {
// 指向自己实例的私有静态引用
private static Singleton2 singleton2;
// 私有的构造方法
private Singleton2(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton2 getSingleton2(){
// 被动创建,在真正需要使用时才去创建
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}123456789101112131415161718
(3). 线程安全的懒汉式单例 —— 内部类方式
public class Singleton {
//静态私有内部类
private static class InnerClass {
private final static Singleton instance = new Singleton();
}
private Singleton(){
}
public static Singleton getInstance(){
return InnerClass.instance;
}
}1234567891011121314
内部类方式线程安全懒汉式单例的内在原理在于:虚拟机会保证一个类的类构造器
(4). 线程安全的懒汉式单例 —— 双重检查方式
public class Singleton {
// volatile: 防止指令重排序
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
// 第一次检查
if(instance == null){
// 只在最初几次会进入该同步块,提高效率
synchronized(Singleton.class){
// 第二次检查
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}1234567891011121314151617181920212223
8、类的生命周期及其初始化时机
类的生命周期主要包括加载、链接、初始化、使用和卸载五个阶段,如下图所示:

其中,虚拟机规范指明 有且只有 五种情况必须立即对类进行初始化,包括:
1). 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时:注意,newarray指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String[]只会直接触发String[]类的初始化,也就是触发对类[Ljava.lang.String的初始化,而直接不会触发String类的初始化时,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:
- 使用new关键字实例化对象的时候;
- 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
- 调用一个类的静态方法的时候。
2). 对类进行反射调用时:使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3). 初始化子类时:当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4). 虚拟机启动时:当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5). 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
9、类加载过程中各阶段的作用
1、 加载(Loading)
(1). 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
(2). 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
(3). 在内存中(对于HotSpot虚拟就而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
2、 验证(Verification):验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
3、准备(Preparation):准备阶段是正式为类变量(static 成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。
4、解析(Resolution):解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
5、初始化(Initialization):初始化阶段是执行类构造器
10、对象的创建过程
在Java中,创建一个对象常常需要经历如下几个过程:父类的类构造器
11、双亲委派模型
双亲委派模型很好地解决了类加载器的统一加载问题:越基础的类由越上层的加载器进行加载,进而保证Java类型体系中最基础的行为,防止应用程序变得混乱。比如,java.lang.Object 类总是由启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类型(是否是同一类型由类加载器与类本身共同决定)。


12、异常机制
Java体系中异常的组织分类如下图所示,所有异常类型的根类为 Throwable,具体包括两大类:Error 与 Exception。其中,Error是指程序无法处理的错误,表示运行应用程序中较严重问题;Exception是指程序本身可以处理的错误,具体可分为运行时异常(派生于 RuntimeException 的异常) 和 其他异常。
此外,从异常是否必须需要被处理的角度来看,异常又可分为不受检查异常和受检查异常两种情况:
-
不受检查异常:派生于 Error 或 RuntimeException 的所有异常;
-
受检查异常:除去不受检查异常的所有异常。

下面着重介绍一下使用finally子句,**在对应的try子句执行的前提下,finally 子句总会被执行。并且,finally子句 总是在诸如return、break、throw和continue等控制转移语句之前执行。**看以下几个经典例子:
1)、 try子句执行,finally子句必然执行
// 代码片段1
public class Test {
public static void main(String[] args) {
try {
System.out.println("try block");
return ;
} finally {
System.out.println("finally block");
}
}
}/* Output:
try block
finally block
*///:~ 1234567891011121314
2)、 try子句执行,finally子句必然执行,且在控制转移语句之前执行
// 代码片段2
public class Test {
public static void main(String[] args) {
System.out.println("reture value of test() : " + test());
}
public static int test(){
int i = 1;
try {
System.out.println("try block");
i = 1 / 0;
return 1;
}catch (Exception e){
System.out.println("exception block");
return 2;
}finally {
System.out.println("finally block");
}
}
}/* Output:
try block
exception block
finally block
reture value of test() : 2
*///:~ 1234567891011121314151617181920212223242526
3)、 try子句执行,finally子句必然执行,且在控制转移语句throw子句之前执行
// 代码片段3
public class ExceptionSilencer {
public static void main(String[] args) {
try {
throw new RuntimeException();
} finally {
// Using ‘return’ inside the finally block
// will silence any thrown exception.
return;
}
}
} ///:~123456789101112
更多关于Java异常机制的相关内容请参见笔者博文《 Java 异常模型综述》。
13、七大设计原则
- 单一职责原则:高内聚,一个类只做它该做的事情;
- 接口隔离原则:接口小而专,避免大而全;
- 依赖倒置原则:依赖抽象而非实现,面向接口编程;
- 里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能;
- 开闭原则:Open for Extension, Closed for Modification,例如 AOP,代理模式,适配器模式就是其经典应用;
- 迪米特法则:高内聚,低耦合;
14、代理模式
根据代理类的创建时机和创建方式的不同,我们可以将代理模式分为静态代理和动态代理两种形式,其中,在程序运行前就已经存在的编译好的代理类是为静态代理,在程序运行期间根据需要动态的创建代理类及其实例来完成具体的功能是为动态代理。其中,代理对象的作用如下:
-
代理对象存在的价值主要用于拦截对真实业务对象的访问;
-
代理对象应该具有和目标对象(真实业务对象)相同的方法,即实现共同的接口或继承于同一个类;
-
代理对象应该是目标对象的增强,否则我们就没有必要使用代理了。

JDK 动态代理是动态代理模式的经典实现,主要包括三个角色对象:Subject (接口)、被代理的类以及InvocationHandler接口(一般持有被代理对象),例如:
- 实现 InvocationHandler 接口
public class ProxyHandler implements InvocationHandler {
private Object proxied; // 被代理对象
public ProxyHandler(Object proxied) {
this.proxied = proxied;
}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
// 在转调具体目标对象之前,可以执行一些功能处理
System.out.println("前置增强处理: yoyoyo...");
// 转调具体目标对象的方法(三要素:实例对象 + 实例方法 + 实例方法的参数)
Object obj = method.invoke(proxied, args);
// 在转调具体目标对象之后,可以执行一些功能处理
System.out.println("后置增强处理:hahaha...");
return obj;
}
}1234567891011121314151617181920212223
- Proxy.newProxyInstance
// 真实对象real
Subject real = new RealSubject();
// 生成real的代理对象
Subject proxySubject = (Subject) Proxy.newProxyInstance(
Subject.class.getClassLoader(), new Class[] { Subject.class },
new ProxyHandler(real));
proxySubject.doSomething();123456789
但是,JDK动态代理只能完成对接口的代理,而不能完成对类的代理,关键原因为:**Java只允许单继承。**具体地,代理对象proxySubject的类型为“com.sun.proxy.$Proxy0”,这恰好印证了proxySubject对象是一个代理对象。除此之外,我们还发现代理对象proxySubject所对应的类继承自java.lang.reflect.Proxy类,这也正是JDK动态代理机制无法实现对class的动态代理的原因。
15、迭代器模式
迭代器模式是与集合共生共死的。一般来说,我们只要实现一个容器,就需要同时提供这个容器的迭代器,使用迭代器的好处是:封装容器的内部实现细节,对于不同的集合,可以提供统一的遍历方式,简化客户端的访问和获取容器内数据。

特别需要注意的是,在迭代器模式中,**具体迭代器角色和具体容器角色是耦合在一起的 —— 遍历算法是与容器的内部细节紧密相关的。**为了使客户程序从与具体迭代器角色耦合的困境中脱离出来,避免具体迭代器角色的更换给客户程序带来的修改,迭代器模式抽象了具体迭代器角色,使得客户程序更具一般性和重用性,这被称为多态迭代。
在 Java Collection FrameWork中,提供的具体迭代器角色是定义在容器角色中的内部类,这样便保护了容器的封装。但是,同时容器也提供了遍历算法接口,并且你可以扩展自己的迭代器。大家考虑一个问题,为什么一定要去实现 Iterable 这个接口呢? 为什么不直接实现 Iterator接口 呢?
看一下 JDK 中的集合类,比如 List一族或者Set一族,都是实现了 Iterable 接口,但并不直接实现 Iterator 接口。仔细想一下这么做是有道理的:因为 Iterator接口的核心方法 next() 或者 hasNext() 是依赖于迭代器的当前迭代位置的。若 Collection 直接实现 Iterator 接口,势必导致集合对象中包含当前迭代位置的数据(指针)。当集合在不同方法间被传递时,由于当前迭代位置不可预置,那么 next() 方法的结果会变成不可预知。除非再为 Iterator接口 添加一个 reset() 方法,用来重置当前迭代位置。但即使这样,Collection 也只能同时存在一个当前迭代位置(不能同时多次迭代同一个序列:必须要等到当前次迭代完成并reset后,才能再一次从头迭代)。 而选择实现 Iterable 接口则不然,每次调用都会返回一个从头开始计数的迭代器(Iterator),因此,多个迭代器间是互不干扰的。
16、适配器模式
适配器模式将一个类的接口转换成客户期望的另一个接口,让原本不兼容的接口可以合作无间。也就是说,适配器模式用于实现新、老接口之间的转换与适配,其魅力在于:不改变原有接口,却还能使用新接口的功能。

适配器模式主要包含以下四个角色,其内涵分别为:
- Target: 客户所期待的接口;
- Adaptee: Adapter 所包装的对象,即被适配的类(适配者);
- Adapter: 一个用于包装不兼容接口的对象的包装类,通过包装一个需要适配的对象,把原接口转换成目标接口;
- Client: 客户端;
适配器模式的三个特点:
- 适配器对象实现原有接口;
- 适配器对象组合一个实现新接口的对象(这个对象也可以不实现一个接口,只是一个单纯的对象);
- 对适配器原有接口方法的调用被 委托 给新接口的实例的特定方法。
适配器模式在Java中最经典的应用为IO中字节流与字符流的转换:
java.io.InputStreamReader(InputStream)
java.io.OutputStreamWriter(OutputStream)123
更多关于适配器模式的内容详见一个示例让你明白适配器模式和 运用适配器模式应对项目中的变化两篇博文。
17、模板方法模式
模板方法模式是一种基于继承的代码复用技术,是一种类行为型模式,其核心在于:定义一个操作中算法的框架,而将一些步骤延迟到子类中。模板方法模式使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

模板方法模式的经典应用包括 HibernateTemplate,AQS 等,其优点包括:
- 封装不变部分,扩展可变部分;
- 提取公共代码,便于维护;
- 行为由父类控制,子类实现。
18、策略模式
策略模式属于对象的行为模式,其用意是针对一组算法,将每一个算法封装到具有共同接口的独立的类中,从而使得它们可以相互替换,核心思想是:面向接口编程。

策略模式的经典应用包括Spring的PlatfromTransactionManager,JDK 排序策略 (不同的Comparator)等,其优点包括:
- 算法可以自由切换,避免使用多重条件判断;
- 扩展性良好。
1). 策略模式与模板方法的区别
对于策略模式而言,一个“策略”是一个整体的(完整的)算法,算法是可以被整体替换的;而模板方法只能被替换其中的特定点,算法流程是固定不可变的。在思想和意图上看,模板方法更加强调:
- 定义一条线(算法流程),线上的多个点是可以变化的(具体实现在子类中完成),线上的多个点一定是会被执行的,并且一定是按照特定流程被执行的。
- 算法流程只有唯一的入口,对于点的访问是受限的。
19、Java 自动装箱、拆箱机制
Java为每种基本数据类型都提供了对应的包装器类型。所谓自动装箱机制就是自动将基本数据类型转换为包装器类型,而自动拆箱机制就是自动将包装器类型转换为基本数据类型。在JDK中,**装箱过程是通过调用包装器的valueOf方法实现的,而拆箱过程是通过调用包装器的 xxxValue方法实现的(xxx代表对应的基本数据类型)。**但是,
- Integer、Short、Byte、Character、Long 这几个类的valueOf方法的实现是类似的,有限可列举,共享[-128,127];
- Double、Float的valueOf方法的实现是类似的,无限不可列举,不共享;
- Boolean的valueOf方法的实现不同于以上的整型和浮点型,只有两个值,有限可列举,共享;
(1). 什么时候装箱/拆箱?
至于什么时候装箱,什么时候拆箱主要取决于:在当前场景下,你需要的是引用类型还是原生类型。若需要引用类型,但传进来的值是原生类型,则自动装箱(例如,使用equals方法时传进来原生类型的值);若需要的是原生类型,但传进来的值是引用类型,则自动拆箱(例如,使用运算符进行运算时,操作数是包装类型)。
更多关于Java自动装箱与拆箱机制的内容可以参见笔者的博文《Java 原生类型与包装器类型深度剖析》。
20、内部类
内部类指的是在一个类的内部所定义的类,类名不需要和源文件名相同。在Java中,内部类是一个编译时的概念,一旦编译成功,内部类和外部类就会成为两个完全不同的类,共有四种类型:
- 成员内部类:成员内部类是外围类的一个成员,是依附于外围类的,所以,只有先创建了外围类对象才能够创建内部类对象。也正是由于这个原因,成员内部类也不能含有 static 的变量和方法;
- 静态内部类:静态内部类,就是修饰为static的内部类,该内部类对象不依赖于外部类对象,就是说我们可以直接创建内部类对象,但其只可以直接访问外部类的所有静态成员和静态方法;
- 局部内部类:局部内部类和成员内部类一样被编译,只是它的作用域发生了改变,它只能在该方法和属性中被使用,出了该方法和属性就会失效;
- 匿名内部类:定义匿名内部类的前提是,内部类必须要继承一个类或者实现接口,格式为 new 父类或者接口(){定义子类的内容(如函数等)}。也就是说,匿名内部类最终提供给我们的是一个 *匿名子类的对象*。
1)、内部类的作用
- 间接实现多重继承,例如:
//父类Example1
public class Example1 {
public String name() {
return "rico";
}
}
//父类Example2
public class Example2 {
public int age() {
return 25;
}
}
//实现多重继承的效果
public class MainExample {
//内部类Test1继承类Example1
private class Test1 extends Example1 {
public String name() {
return super.name();
}
}
//内部类Test2继承类Example2
private class Test2 extends Example2 {
public int age() {
return super.age();
}
}
public String name() {
return new Test1().name();
}
public int age() {
return new Test2().age();
}
public static void main(String args[]) {
MainExample mexam = new MainExample();
System.out.println("姓名:" + mexam.name());
System.out.println("年龄:" + mexam.age());
}
}/* Output:
姓名:rico
年龄:25
*///:~ 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
- 内部类还可以很好的实现隐藏(一般的非内部类,是不允许有 private 与 protected 权限的,但内部类可以)。
21、equals, hashCode, ==
- == 用于判断两个对象是否为同一个对象或者两基本类型的值是否相等;
- equals 用于判断两个对象内容是否相同;
- hashCode 是一个对象的 消息摘要函数,一种 压缩映射,其一般与equals()方法同时重写;若不重写hashCode方法,默认使用Object类的hashCode方法,该方法是一个本地方法,由 Object 类定义的 hashCode 方法会针对不同的对象返回不同的整数。
1). equals与hashCode的区别
- 一般来讲,equals 这个方法是给用户调用的,而 hashcode 方法一般用户不会去调用 ;
- 当一个对象类型作为集合对象的元素时,那么这个对象应该拥有自己的equals()和hashCode()设计,而且要遵守前面所说的几个原则。
2). 在HashMap中使用可变对象作为Key带来的问题
HashMap用Key的哈希值来存储和查找键值对,如果HashMap Key的哈希值在存储键值对后发生改变,那么Map可能再也查找不到这个Entry了。也就是说,在HashMap中可变对象作为Key会造成 数据丢失。因此,
- 在HashMap中尽量使用不可变对象作为Key,比如,使用String、Integer等不可变类型用作Key是非常明智的或者使用自己定义的不可变类。
- 如果可变对象在HashMap中被用作键,那就要小心在改变对象状态的时候,不要改变它的哈希值了,例如,可以只根据对象的标识属性生成HashCode。
public class MutableSafeKeyDemo {
public static void main(String[] args) {
Employee emp = new Employee(2);
emp.setName("Robin");
// Put object in HashMap.
Map<Employee, String> map = new HashMap<>();
map.put(emp, "Showbasky");
System.out.println(map.get(emp));
// Change Employee name. Change in 'name' has no effect
// on hash code.
emp.setName("Lily");
System.out.println(map.get(emp));
}
}
class Employee {
// It is specified while object creation.
// Cannot be changed once object is created. No setter for this field.
private int id;
private String name;
public Employee(final int id) {
this.id = id;
}
public final String getName() {
return name;
}
public final void setName(final String name) {
this.name = name;
}
public int getId() {
return id;
}
// Hash code depends only on 'id' which cannot be
// changed once object is created. So hash code will not change
// on object's state change
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + id;
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Employee other = (Employee) obj;
if (id != other.id)
return false;
return true;
}
}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
3). 重写equals但不重写HashCode会出现的问题
在使用Set时,若向其加入两个相同(equals返回为true)的对象,由于hashCode函数没有进行重写,那么这两个对象的hashCode值必然不同,它们很有可能被分散到不同的桶中,容易造成重复对象的存在。
4). JDK中equals()方法实现逻辑
-
先 比较引用是否相同(是否为同一对象),
-
再 判断类型是否一致(是否为同一类型),
-
最后 比较内容是否一致
更多关于Java中关于相等的内容可以参见笔者的博文《Java 中的 ==, equals 与 hashCode 的区别与联系》。
22、什么是不可变对象
一个不可变对象应该满足以下几个条件:
- 基本类型变量的值不可变;
- 引用类型变量不能指向其他对象;
- 引用类型所指向的对象的状态不可变;
- 除了构造函数之外,不应该有其它任何函数(至少是任何public函数)修改任何成员变量;
- 任何使成员变量获得新值的函数都应该将新的值保存在新的对象中,而保持原来的对象不被修改。
23、Java的序列化/反序列化机制
(1)、使用Serializable序列化/反序列化
将实现了Serializable接口的对象转换成一个字节序列,并能够在以后将这个字节序列完全恢复为原来的对象,序列化可以弥补不同操作系统之间的差异。其中,需要注意以下几点:
- 需要序列化的对象必须实现Serializable接口;
- 只有非静态字段和非transient字段进行序列化,与字段的可见性无关;
- 序列化/反序列化的实质上操纵的是一个对象图;
public class Student implements Cloneable, Serializable {
private int id;
public Student(Integer id) {
this.id = id;
}
@Override
public String toString() {
return "Student [id=" + id + "]";
}
public static void main(String[] args) throws Exception {
Constructor<Student> constructor = Student.class
.getConstructor(Integer.class);
Student stu3 = constructor.newInstance(123);
// 写对象
ObjectOutputStream output = new ObjectOutputStream(
new FileOutputStream("student.bin"));
output.writeObject(stu3);
output.close();
// 读对象
ObjectInputStream input = new ObjectInputStream(new FileInputStream(
"student.bin"));
Student stu5 = (Student) input.readObject();
System.out.println(stu5);
}
}1234567891011121314151617181920212223242526272829303132
此外,Java中常用到的序列化方法还有 XML、JSON 等,此不赘述。
24、String,StringBuilder 以及 StringBuffer
关于这三个字符串类的异同之处主要关注可变不可变、安全不安全两个方面:
- StringBuffer(同步的)和String(不可变的)都是线程安全的,StringBuilder是线程不安全的;
- String是不可变的,StringBuilder和StringBuffer是不可变的;
- String的连接操作的底层是由StringBuilder实现的;
- 三者都是final的,不允许被继承;
- StringBuilder 以及 StringBuffer 都是抽象类AbstractStringBuilder的子类,它们的接口是相同的。
1). 不可变类String的原因
-
String主要的三个成员变量 char value[], int offset, int count均是private,final的,并且没有对应的 getter/setter;
-
String 对象一旦初始化完成,上述三个成员变量就不可修改;并且其所提供的接口任何对这些域的修改都将返回一个新对象;
关于Java字符串类String的更多介绍请见笔者的博文《Java String 综述(上篇)》和《Java String 综述(下篇)》。
25、重载,重写,隐藏
-
重载:类内多态,静态绑定机制(编译时已经知道具体执行哪个方法),方法同名,参数不同
-
重写:类间多态,动态绑定机制(运行时确定),实例方法,两小两同一大(方法签名相同,子类的方法所抛出的异常、返回值的范围不大于父类的对应方法,子类的方法可见性不小于父类的对应方法)方法签名相同,子类的方法所抛出的异常、返回值的范围不大于父类的对应方法,子类的方法可见性不小于父类的对应方法
-
隐藏:编译期绑定,静态方法和成员变量
关于重载、重写和隐藏等重要概念的详细解释请参考笔者的博文《Java 继承、多态与类的复用》。
26、抽象,封装,继承,多态
Java 的四大特性总结如下:
- 封装:把对象的属性和行为(数据)封装为一个独立的整体,并尽可能隐藏对象的内部实现细节;
- 继承:一种代码重用机制;
- 多态:分离了做什么和怎么做,从另一个角度将接口和实现分离开来,消除类型之间的耦合关系;表现形式:重载与重写;
- 抽象:对继承的另一种表述;表现形式:接口(契约)与抽象类(模板)。
27、ArrayList(动态数组)、LinkedList(带头结点的双向链表)
1). ArrayList
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable12
- 默认初始容量为 10;
- 扩容机制:添加元素前,先检查是否需要扩容,一般扩为源数组的 1.5 倍 + 1;
- 边界检查(即检查 ArrayList 的 Size):涉及到 index 的操作;
- 调整数组容量(减少容量):将底层数组的容量调整为当前列表保存的实际元素的大小;
- 在查找给定元素索引值等的方法中,源码都将该元素的值分为null和不为null两种情况处理;
2). LinkedList
LinkedList 不但实现了List接口,还实现了Dequeue接口。因此,LinkedList不但可以当做List来用,还可以当做Stack(push、pop、peek),Queue(offer、poll、peek)来使用。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable123
private static class Entry<E> {
E element;
Entry<E> next;
Entry<E> previous;
Entry(E element, Entry<E> next, Entry<E> previous) {
this.element = element;
this.next = next;
this.previous = previous;
}
}1234567891011

3). ArrayList与LinkedList比较
- ArrayList是基于数组的实现,LinkedList是基于带头结点的双向循环链表的实现;
- ArrayList支持随机访问,LinkedList不支持;
- LinkedList可作队列和栈使用,实现了Dequeue接口,而ArrayList没有;
- ArrayList 寻址效率较高,插入/删除效率较低;LinkedList插入/删除效率较高,寻址效率较低
4). Fast-fail机制
**Fail-Fast 是 Java 集合的一种错误检测机制,**为了防止在某个线程在对Collection进行迭代时,其他线程对该Collection进行结构上的修改。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险,抛出 ConcurrentModificationException 异常。
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = ArrayList.this.modCount;
public boolean hasNext() {
return (this.cursor != ArrayList.this.size);
}
public E next() {
checkForComodification();
/** 省略此处代码 */
}
public void remove() {
if (this.lastRet < 0)
throw new IllegalStateException();
checkForComodification();
/** 省略此处代码 */
}
final void checkForComodification() {
if (ArrayList.this.modCount == this.expectedModCount)
return;
throw new ConcurrentModificationException();
}
} 123456789101112131415161718192021222324252627
更多关于 Java List 以及 Fast-fail机制的介绍请参见笔者的博文《Java Collection Framework : List》。
28、Set (HashSet,LinkedHashSet,TreeSet)
Set不包含重复的元素,这是Set最大的特点,也是使用Set最主要的原因。常用到的Set实现有 HashSet,LinkedHashSet 和 TreeSet。一般地,如果需要一个访问快速的Set,你应该使用HashSet;当你需要一个排序的Set,你应该使用TreeSet;当你需要记录下插入时的顺序时,你应该使用LinedHashSet。

-
HashSet:委托给HashMap进行实现,实现了Set接口
HashSet是采用hash表来实现的,其中的元素没有按顺序排列,add()、remove()以及contains()等方法都是复杂度为O(1)的方法。
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
static final long serialVersionUID = -5024744406713321676L;
// 底层支持,HashMap可以代表一个HashMap,也可以代表一个LinkedHashMap
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object(); // 傀儡对象
1234567891011
五个构造方法 = 4(基于HashMap的实现,用于实现HashSet) + 1(基于LinkedHashMap的实现,用于实现LinkedHashSet)
-
LinkedHashSet:是HashSet的子类,被委托给HashMap的子类LinkedHashMap进行实现,实现了Set接口
LinkedHashSet继承于HashSet,利用下面的HashSet构造函数即可,注意到,其为包访问权限,专门供LinkedHashSet的构造函数调用。LinkedHashSet性能介于HashSet和TreeSet之间,是HashSet的子类,也是一个hash表,但是同时维护了一个双链表来记录插入的顺序,基本方法的复杂度为O(1)。
/**
* Constructs a new, empty linked hash set. (This package private
* constructor is only used by LinkedHashSet.) The backing
* HashMap instance is a LinkedHashMap with the specified initial
* capacity and the specified load factor.
*
* @param initialCapacity the initial capacity of the hash map
* @param loadFactor the load factor of the hash map
* @param dummy ignored (distinguishes this
* constructor from other int, float constructor.)
* @throws IllegalArgumentException if the initial capacity is less
* than zero, or if the load factor is nonpositive
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<E,Object>(initialCapacity, loadFactor);
}12345678910111213141516
-
TreeSet:委托给TreeMap(TreeMap实现了NavigableSet接口)进行实现,实现了NavigableSet接口(扩展的 SortedSet)
TreeSet是采用树结构实现(红黑树算法),元素是按顺序进行排列,但是add()、remove()以及contains()等方法都是复杂度为O(log (n))的方法,它还提供了一些方法来处理排序的set,如first()、 last()、 headSet()和 tailSet()等。此外,TreeSet不同于HashSet和LinkedHashSet,其所存储的元素必须是可排序的(元素实现Comparable接口或者传入Comparator),并且不能存放null值。
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
/**
* The backing map.
*/
private transient NavigableMap<E,Object> m; // 底层支持
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
// 默认构造函数
public TreeSet() {
this(new TreeMap<E,Object>());
}123456789101112131415
29、Map及Map的三种常用实现[链表数组HashMap,LinkedHashMap(HashMap的子类,带头结点的双向链表 + HashMap),基于红黑树的TreeMap(对key排序)]
1)、HashMap:哈希表

(1)、对NULL键的特别处理
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中。
(2)、HashMap 中的哈希策略
- 使用 hash() 方法用于对Key的hashCode进行重新计算,以防止质量低下的hashCode()函数实现。由于hashMap的支撑数组长度总是 2 的倍数,通过右移可以使低位的数据尽量的不同,从而使Key的hash值的分布尽量均匀;
- 使用 indexFor() 方法进行取余运算,以使Entry对象的插入位置尽量分布均匀
(3)、HashMap 的底层数组长度为何总是2的n次方?
- 构造函数及扩容策略
// HashMap 的容量必须是2的幂次方,超过 initialCapacity 的最小 2^n
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1; 1234
- 当底层数组的length为2的n次方时,h&(length - 1) 就相当于对length取模,而且速度比直接取模快得多,这是HashMap在速度上的一个优化
(4)、初始容量16,两倍扩容,先添加再检查
2)、LinkedHashMap:HashMap + 带头结点的双向循环链表


(1)、可以实现LRU算法
public class LRU<K,V> extends LinkedHashMap<K, V> implements Map<K, V>{
private static final long serialVersionUID = 1L;
public LRU(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor, accessOrder);
}
/**
* @description 重写LinkedHashMap中的removeEldestEntry方法,当LRU中元素多余6个时,
* 删除最不经常使用的元素
* @author rico
* @created 2017年5月12日 上午11:32:51
* @param eldest
* @return
* @see java.util.LinkedHashMap#removeEldestEntry(java.util.Map.Entry)
*/
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
// TODO Auto-generated method stub
if(size() > 6){
return true;
}
return false;
}
public static void main(String[] args) {
LRU<Character, Integer> lru = new LRU<Character, Integer>(
16, 0.75f, true);
String s = "abcdefghijkl";
for (int i = 0; i < s.length(); i++) {
lru.put(s.charAt(i), i);
}
System.out.println("LRU中key为h的Entry的值为: " + lru.get('h'));
System.out.println("LRU的大小 :" + lru.size());
System.out.println("LRU :" + lru);
}
}123456789101112131415161718192021222324252627282930313233343536373839404142
(2)、维持插入顺序或者维持访问顺序:双向循环链表头结点header + accessOrder
(3)、利用双向循环链表重写迭代器
3)、TreeMap:红黑树的实现
(1). 排序二叉树 Vs. 红黑树
排序二叉树虽然可以快速检索,但在最坏的情况下:如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成链表:所有节点只有左节点(如果插入节点集本身是大到小排列);或所有节点只有右节点(如果插入节点集本身是小到大排列)。在这种情况下,排序二叉树就变成了普通链表,其检索效率就会很差。
(2). 红黑树
为了改变排序二叉树存在的不足,Rudolf Bayer 与 1972 年发明了另一种改进后的排序二叉树:红黑树,他将这种排序二叉树称为“对称二叉 B 树”,而红黑树这个名字则由 Leo J. Guibas 和 Robert Sedgewick 于 1978 年首次提出。 红黑树是一个更高效的检索二叉树,因此常常用来实现关联数组。典型地,**JDK 提供的集合类 TreeMap 本身就是一个红黑树的实现。**红黑树在原有的排序二叉树增加了如下几个要求:
性质 1:每个节点要么是红色,要么是黑色; 性质 2:根节点永远是黑色的; 性质 3:所有的叶节点都是空节点(即 null),并且是黑色的; 性质 4:每个红色节点的两个子节点都是黑色(从每个叶子到根的路径上不会有两个连续的红色节点); 性质 5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。

(3). 红黑树的性质
上面的性质3中指出:红黑树的每个叶子节点都是空节点,而且并叶子节点都是黑色。但 Java 实现的红黑树将使用null来代表空节点,因此遍历红黑树时将看不到黑色的叶子节点,反而看到每个叶子节点都是红色的。根据性质 5,红黑树从根节点到每个叶子节点的路径都包含相同数量的黑色节点,因此从根节点到叶子节点的路径中包含的黑色节点数被称为树的“黑色高度(black-height)”。性质 4 则保证了从根节点到叶子节点的最长路径的长度不会超过任何其他路径的两倍。
假如有一棵黑色高度为 3 的红黑树:从根节点到叶节点的最短路径长度是 2,该路径上全是黑色节点(黑节点 - 黑节点 - 黑节点)。最长路径也只可能为 4,在每个黑色节点之间插入一个红色节点(黑节点 - 红节点 - 黑节点 - 红节点 - 黑节点),性质 4 保证绝不可能插入更多的红色节点。由此可见,红黑树中最长路径就是一条红黑交替的路径。由此我们可以得出结论:对于给定的黑色高度为 N 的红黑树,从根到叶子节点的最短路径长度为 N-1,最长路径长度为 2 * (N-1)。
(4). 红黑树的查找、插入与删除操作
由于红黑树只是一个特殊的排序二叉树,因此对红黑树上的只读操作与普通排序二叉树上的只读操作完全相同,只是红黑树保持了大致平衡,因此检索性能比排序二叉树要好很多。 但在红黑树上进行插入操作和删除操作会导致树不再符合红黑树的特征,因此插入操作和删除操作都需要进行一定的维护,以保证插入节点、删除节点后的树依然是红黑树。
(5). 红黑树和平衡二叉树
我们知道,在AVL树中,任何节点的两个子树的最大高度差为1,所以它也被称为高度平衡树;而红黑树并不追求 完全平衡,它只要求大致地达到平衡要求,降低了对旋转的要求,从而提高了性能。实际上,由于红黑树的设计,任何不平衡都会在三次旋转之内解决。
红黑树能够以O(log2 n) 的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构,能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高。
红黑树并不是真正的平衡二叉树,但在实际应用中,红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高。
排序二叉树的深度直接影响了检索的性能,正如前面指出,当插入节点本身就是由小到大排列时,排序二叉树将变成一个链表,这种排序二叉树的检索性能最低:N 个节点的二叉树深度就是 N-1。 红黑树通过上面这种限制来保证它大致是平衡的——因为红黑树的高度不会无限增高,这样保证红黑树在最坏情况下都是高效的,不会出现普通排序二叉树的情况。
更多关于HashMap的介绍请参见笔者的博文《Map 综述(一):彻头彻尾理解 HashMap》;更多关于LinkedHashMap的介绍请参见笔者的博文《 Map 综述(二):彻头彻尾理解 LinkedHashMap》;更多关于TreeMap以及红黑树的内容请参考博文通过分析 JDK 源代码研究 TreeMap 红黑树算法实现和从排序二叉树到红黑树与AVL树(转)。
30、容器中判断两个对象是否相等的步骤
- 判断两个对象的hashCode是否相等:如果不相等,认为两个对象也不相等,完毕;如果相等,转入2);
- 判断两个对象用equals运算是否相等:如果不相等,认为两个对象也不相等;如果相等,认为两个对象相等。
31、ConcurrentHashMap
1). HashMap 线程不安全的典型表现
- HashMap进行扩容重哈希时导致Entry链形成环。一旦Entry链中有环,势必会导致在同一个桶中进行插入、查询、删除等操作时陷入死循环。
2). ConcurrentHashMap 原理图

3). 写操作:put在链头加元素,remove/clear不会对原有链进行修改
-
put:定位段 —> 对段加锁 ——> 扩容检查 ——> Key检查 ——> 链头插入
先检查key是否不为空,然后对key.hashcode进行再哈希,并根据该值与并发级别(2^n)的高n位定位至某一段,然后将put操作委托给该段。该段在put一条记录时,会首先定位至段中某一特定的桶并对该段上锁,然后检查该段是否需要扩容,并查找该单链表中是否已经存在指定Key,若存在,则更新Value值;否则,将其插入都桶中第一个节点,并更新该段的节点的个数Count,解锁。
-
remove:定位段 —> 对段加锁 ——> Key检查 ——>Entry链复制(原始链表并没有被修改)——> 删除成功

- clear:对segments数组中的每个段进行清空 -> 每个Segments中的桶置空(原始链表任然存在)
/**
* Removes all of the mappings from this map.
*/
public void clear() {
for (int i = 0; i < segments.length; ++i)
segments[i].clear();
}1234567
4). 为什么可以不加锁进行读?
具体分两种情况对待:对ConcurrentHashMap做非结构性修改和对ConcurrentHashMap做结构性修改。
-
对ConcurrentHashMap做非结构性修改
非结构性修改操作只是更改某个HashEntry的value字段的值。由于对Volatile变量的写入操作将与随后对这个变量的读操作进行同步,所以当一个写线程修改了某个HashEntry的value字段后,Java内存模型能够保证读线程一定能读取到这个字段更新后的值。所以,写线程对链表的非结构性修改能够被后续不加锁的读线程看到。
-
对ConcurrentHashMap做结构性修改
对ConcurrentHashMap做结构性修改时,实质上是对某个桶指向的链表做结构性修改。如果能够确保在读线程遍历一个链表期间,写线程对这个链表所做的结构性修改不影响读线程继续正常遍历这个链表,那么读/写线程之间就可以安全并发访问这个ConcurrentHashMap。在ConcurrentHashMap中,结构性修改操作包括put操作、remove操作和clear操作,下面我们分别分析这三个操作:
(1). clear操作只是把ConcurrentHashMap中所有的桶置空,每个桶之前引用的链表依然存在,只是桶不再引用这些链表而已,而链表本身的结构并没有发生任何修改。因此,正在遍历某个链表的读线程依然可以正常执行对该链表的遍历。
(2). 关于put操作的细节我们在上文已经单独介绍过,我们知道put操作如果需要插入一个新节点到链表中时会在链表头部插入这个新节点,此时链表中的原有节点的链接并没有被修改。也就是说,插入新的健/值对到链表中的操作不会影响读线程正常遍历这个链表。
(3). 在执行remove操作时,原始链表并没有被修改,也就是说,读线程不会受同时执行 remove 操作的并发写线程的干扰。
综合上面的分析我们可以知道,无论写线程对某个链表进行结构性修改还是非结构性修改,都不会影响其他的并发读线程对这个链表的访问。
5). 跨段操作, size、contains
/**
* Returns the number of key-value mappings in this map. If the
* map contains more than <tt>Integer.MAX_VALUE</tt> elements, returns
* <tt>Integer.MAX_VALUE</tt>.
*
* @return the number of key-value mappings in this map
*/
public int size() {
final Segment<K,V>[] segments = this.segments;
long sum = 0;
long check = 0;
int[] mc = new int[segments.length];
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
check = 0;
sum = 0;
int mcsum = 0;
for (int i = 0; i < segments.length; ++i) {
sum += segments[i].count;
mcsum += mc[i] = segments[i].modCount; // 在统计size时记录modCount
}
if (mcsum != 0) {
for (int i = 0; i < segments.length; ++i) {
check += segments[i].count;
if (mc[i] != segments[i].modCount) { // 统计size后比较各段的modCount是否发生变化
check = -1; // force retry
break;
}
}
}
if (check == sum)// 如果统计size前后各段的modCount没变,且两次得到的总数一致,直接返回
break;
}
if (check != sum) { // Resort to locking all segments // 加锁统计
sum = 0;
for (int i = 0; i < segments.length; ++i) // 先对获取各个段的锁
segments[i].lock();
for (int i = 0; i < segments.length; ++i) // 获取所有段的锁后,进行求和操作
sum += segments[i].count;
for (int i = 0; i < segments.length; ++i) // 求和完成后,释放锁
segments[i].unlock();
}
if (sum > Integer.MAX_VALUE)
return Integer.MAX_VALUE;
else
return (int)sum;
}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
size方法主要思路是先在没有锁的情况下对所有段大小求和,这种求和策略最多执行RETRIES_BEFORE_LOCK次(默认是两次):在没有达到RETRIES_BEFORE_LOCK之前,求和操作会不断尝试执行(这是因为遍历过程中可能有其它线程正在对已经遍历过的段进行结构性更新);在超过RETRIES_BEFORE_LOCK之后,如果还不成功就在持有所有段锁的前提下再对所有段大小求和。
那么,ConcurrentHashMap是如何判断在统计的时候容器的段发生了结构性更新了呢?我们在前文中已经知道,Segment包含一个modCount成员变量,在会引起段发生结构性改变的所有操作(put操作、 remove操作和clean操作)里,都会将变量modCount进行加1,因此,JDK只需要在统计size前后比较modCount是否发生变化就可以得知容器的大小是否发生变化。
更多关于ConcurrentHashMap的介绍请参见笔者的博文《 Map 综述(三):彻头彻尾理解 ConcurrentHashMap》。
32、ConcurrentHashMap,HashMap 与 HashTable
- 本质:三者都实现了Map接口,ConcurrentHashMap和HashMap是AbstractMap的子类,HashTable是Dictionary的子类;
- 线程安全性:HashMap 是线程不安全的,但 ConcurrentHashMap 和 HashTable 是线程安全的,但二者保证线程安全的策略不同;前者采用的是分段锁机制,默认理想情况下,可支持16个线程的并发写和任意线程的并发读,效率较高;HashTable 采用的是同步操作,效率较低
- 键值约束:HashMap 允许键、值为 null,但 ConcurrentHashMap 和 HashTable 既不允许键为null,也不允许值为 null;
- 哈希策略:三者哈希策略不同,HashTable是key.hashCode取余;ConcurrentHashMap与HashMap都是先对hashCode进行再哈希,然后再与(桶数 - 1)进行取余运算,但是二者的再哈希算法不同;
- 扩容机制:扩容检查机制不同,ConcurrentHashMap 和 HashTable 在插入元素前检查,HashMap 在元素插入后检查;
- 初始容量:HashTable 初始容量 11,扩容 2倍 + 1;HashMap初始容量16,扩容2倍
33、什么是CopyOnWrite容器 (弱一致性)
CopyOnWrite 容器即写时复制的容器,从JDK1.5开始,Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器 —— CopyOnWriteArrayList 和 CopyOnWriteArraySet,它们适用于 读操作远多于写操作 的并发场景中。关于写时复制容器,通俗的理解是,当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是,我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以,CopyOnWrite容器也是一种 读写分离思想,读和写不同的容器。写时复制容器也存在一些缺点,比如:
- 容器对象的复制需要一定的开销,如果对象占用内存过大,可能造成频繁的YoungGC和Full GC,对内存的消耗太大;
- CopyOnWriteArrayList不能保证数据严格的实时一致性,只能保证最终一致性。
// volatile 数组,其可见性在于array是否指向了新的对象/数组从而保证可见性,而对数组中元素的变化不起作用。
private volatile transient Object[] array;
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}1234567891011121314151617
1). CopyOnWriteArraySet 与 CopyOnWriteArrayList 的对比
正如Set一般由Map实现一样,CopyOnWriteArraySet容器也是基于CopyOnWriteArrayList容器实现的,只需要在写操作时判断元素不可重复即可。
public class CopyOnWriteArraySet<E> extends AbstractSet<E>
implements java.io.Serializable {
private static final long serialVersionUID = 5457747651344034263L;
private final CopyOnWriteArrayList<E> al;12345
2). 并发容器(CopyOnWriteArrayList/CopyOnWriteArraySet)与同步容器(Collections.synchronizedList / Collections.synchronizedSet)的区别
CopyOnWriteArrayList和Collections.synchronizedList是实现线程安全的列表的两种方式。两种实现方式分别针对不同情况有不同的性能表现,其中CopyOnWriteArrayList的写操作性能较差(复制数组),而多线程的读操作性能较好;而Collections.synchronizedList的写操作性能比CopyOnWriteArrayList在多线程操作的情况下要好很多,而读操作因为是采用了synchronized关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。
3). CopyOnWrite容器的读写操作
- 写操作(add, set, …):使用lock锁(concurrent包下的实现,若使用到锁,均为lock锁)实现
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}1234567891011121314
- 读操作(get, …):不使用锁,弱一致性 (效率与安全性的一个平衡)
public E get(int index) {
return get(getArray(), index);
}123
34、BIO, NIO, AIO
按照《Unix网络编程》的划分,IO模型可以分为:阻塞IO、非阻塞IO、IO复用、信号驱动IO 和 异步IO。按照POSIX标准来划分只分为两类:同步IO和异步IO。如何区分呢?首先,一个IO操作其实分成了两个步骤: 发起IO请求 和 处理IO请求。
阻塞IO和非阻塞IO的区别在于发起IO请求是否阻塞:发起IO请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞IO;如果不阻塞,那么就是非阻塞IO。
同步IO和异步IO的区别就在于处理IO请求是否阻塞:如果实际的IO请求处理由应用内线程完成,那么就是同步IO,因此阻塞IO、非阻塞IO、IO复用、信号驱动IO都是同步IO;如果实际的IO请求处理由操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO。
更多关于BIO、NIO与AIO之间的区别参见博文BIO | NIO | AIO释疑。
1). BIO
BIO是同步阻塞IO(一个线程处理一个链接,双方阻塞等待数据准备好,因此会受到网络延迟等因素的影响),服务端通过一个独立的Acceptor线程监听客户端的连接请求,当它收到客户端的连接请求后,就为每一个客户端创建一个新的线程去处理请求,处理结束后,将处理结果返回给客户端。可以通过线程池技术改良,避免了为每个请求都创建一个新的线程造成的线程资源耗尽问题,但仍然是同步阻塞模型,解决不了根本问题。由于IO请求的处理工作是由应用内线程完成的,因此是同步的。适用于链接数目比较小的架构。

2). NIO
NIO是同步非阻塞IO(数据准备好了再通知我,因此不会受到网络延迟等因素的影响),客户端发起的每个IO连接都注册到多路复用器Selector上,同时Selector会不断轮询注册在其上的Channel,当其中的某些Channel发生读或者写事件时,这些channel就会处于就绪状态,多路复用器Selector就会将这些Channel选择出来逐个处理请求,处理结束后,将处理结果返回到客户端。NIO模型中只需要一个线程负责Selector的轮询就可以接入成千上万的客户端,这相对于BIO来说无疑是巨大的进步。由于IO请求的处理工作是由应用内线程完成的,因此是同步的。适用于链接数目多且短的架构。更多关于NIO的阐述参见博文《Selector及SelectionKey》。

-
Channel
类似于BIO中的Stream,但是Channel是非阻塞的、双向的。可以将NIO中的Channel同传统IO中的Stream来类比,但是要注意,传统IO中,Stream是单向的,比如InputStream只能进行读取操作,OutputStream只能进行写操作。而Channel是双向的,既可用来进行读操作,又可用来进行写操作。
-
Buffer
NIO中所有数据的读和写都离不开Buffer,读取的数据只能放在Buffer中。同样地,写入数据也是先写入到Buffer中。

-
Selector
Selector的作用就是轮询每个注册的Channel,一旦发现Channel有注册的读/写事件发生,便获取事件然后进行处理。
3). AIO
AIO是异步非阻塞IO (IO请求处理完成后再通知我):服务器端异步的处理客户端的连接,并且客户端的IO请求都是由操作系统先完成了,再通知应用内线程进行处理,因此更适合连接数多且连接比较长的架构,例如相册服务器。
4). BIO、NIO 及 AIO 对比

35、引用的种类及其定义 : *弹性的垃圾回收机制*
1). 引入多种引用的目的
无论采用的是引用计数法还是可达性分析算法,判断对象是否可以回收都与其引用有关。在JDK1.2之前,一个对象只有引用和被引用两种状态,即只有“被引用”和“未被引用”,但是这样太过狭隘,我们希望有一种弹性的垃圾回收机制,当内存足够时,将一些对对象保留在内存中;内存比较紧张时,将这些对象回收,类似于缓存的功能,提高效率。
2). 引用的种类
- 强引用:在程序代码之中普遍存在的,用来描述new出来的对象那种。对于类似“Object obj = new Object()”这类引用。 只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用:软引用用来描述一些有用但并非必需的对象,内存泄露之前回收。 对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
- 弱引用:弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,其所关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
- 虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
36、System.gc() 与 Object.finalli()ze
System.gc()方法的作用是发出通知让垃圾回收器启动垃圾回收,但垃圾回收器不一定会真正工作,实际上调用的是Runtime.getRuntime.gc()方法。
Object.finallize()方法的作用为,当对象没有与GC Roots相连接的引用链时,若该对象重写了该方法并且还没有被垃圾回收器调用过,垃圾回收器将调用此方法,这也是对象垃圾回收前最后一次自我解救(重新与GC Roots连上)的方式。实际上,每个对象的finallize方法只会被垃圾回收器调用一次。其方法签名为
protected void finalize() throws Throwable { }1
37、函数式接口
函数式接口(functional interface,也叫功能性接口),简单来说,函数式接口是只有一个抽象方法的接口,其用于支持Lamda表达式,是Lamda表达式的类型。比如,Java标准库中的java.lang.Runnable、java.util.concurrent.Callable、java.util.Comparator都是典型的函数式接口。
Java 8提供注解@FunctionalInterface标识函数式接口,但这个注解是非必须的,只要接口符合函数式接口的标准(即只包含一个方法的接口),虚拟机会自动判断,但最好在接口上使用注解@FunctionalInterface进行声明,以免团队的其他人员错误地往接口中添加新的方法。
Java中的lambda无法单独出现,它需要一个函数式接口来支持,lambda表达式方法体其实就是函数接口的实现。
38、JDK 8 新特性
JDK 8 引进了许多新特性,其中最重要的有以下三点:
1). jdk1.8接口支持静态方法与默认方法(通过default关键字实现);
2). lambda表达式
lambda表达式允许你通过表达式来代替函数式接口,lambda表达式就和方法一样,它提供了一个正常的参数列表和一个方法体(body,可以是一个表达式或一个代码块)。Lamda表达式是由函数式接口所支持的,函数式接口是只有一个抽象方法的接口,是Lamda表达式的类型。一个lambda包括三部分:
-
一个括号内用逗号分隔的形式参数,参数是函数式接口里面方法的参数;
-
一个箭头符号:->
-
方法体,可以是表达式和代码块,方法体是函数式接口里面方法的实现:如果是代码块,则必须用{}来包裹起来,且需要一个return返回值,但有个例外,若函数式接口里面方法返回值是void,则无需{}。

public class TestLambda {
// 新的使用方式
public static void runThreadUseLambda() {
//Runnable是一个函数接口,只包含了有个无参数的,返回void的run方法;
//所以lambda表达式左边没有参数,右边也没有return,只是单纯的打印一句话
new Thread(() ->System.out.println("lambda实现的线程")).start();
}
// 老的使用方式
public static void runThreadUseInnerClass() {
//这种方式就不多讲了,以前旧版本比较常见的做法
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("内部类实现的线程");
}
}).start();
}
public static void main(String[] args) {
TestLambda.runThreadUseLambda();
TestLambda.runThreadUseInnerClass();
}
}1234567891011121314151617181920212223
使用Lambda表达式可以带来以下好处:
- 更加紧凑的代码,可读性更强。比如,Java中现有的匿名内部类以及监听器(listeners)和事件处理器(handlers)都显得很冗长。
- 修改方法的能力。比如,Collection接口的contains方法,当且仅当传入的元素真正包含在集合中,才返回true。而假如我们想对一个字符串集合,传入一个字符串,只要这个字符串出现在集合中(忽略大小写)就返回true。简单地说,我们想要的是传入“一些我们自己的代码”到已有的方法中,已有的方法将会执行我们传入的代码。Lambda表达式能很好地支持这点。
- 更好地支持多核处理。例如,通过Java 8新增的Lambda表达式,我们可以很方便地并行操作大集合,充分发挥多核CPU的潜能。
3). StreamAPI + Lamda
Stream API更像具有Iterable的集合类,但行为和集合类又有所不同,它是对集合对象功能的增强,专注于对集合对象进行各种非常便捷、高效的聚合操作或大批量数据操作。
在Stream API中,一个流基本上代表一个元素序列,Stream API提供了丰富的操作函数来计算这些元素。以前我们在开发业务应用时,通常很多操作的实现是这样做的:我们使用循环对集合做遍历,针对集合中的元素实现各种操作,定义各种变量来实现目的,这样我们就得到了一大堆丑陋的顺序代码。如果我们使用StreamAPI做同样的事情,使用Lambda表达式和其它函数进行抽象,可以使得代码更易于理解、更为干净。有了这些抽象,还可以做一些优化,比如实现并行等,例如:
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Stream<Integer> stream = numbers.stream();
stream.filter((x) -> {
return x % 2 == 0; // 将偶数过滤出来
}).map((x) -> {
return x * x; // 将偶数平方
}).forEach(System.out::println); // 打印结果
}123456789
更多关于 Java 8 的新特性可以才参考《Java基础知识总结之1.8新特性lambda表达式》 一文。
39、双亲委派模型 与 反双亲委派模型
1). 双亲委派模型:基础类的同一加载问题
双亲委派模型工作过程为:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把加载请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此,所有的加载请求最终都应该传送到顶层的启动类加载器当中,只有当父加载器反馈自己无法完成这个加载请求后,子加载器才会尝试自己去加载。
使用双亲委派模型的好处是,Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如Object类,它位于rt.jar包中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是使用同一类。相反,如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果一个用户自己写了java.lang.Object类并放到程序的ClassPath中,那么系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将一片混乱。

2). 违反双亲委派模型
双亲委派模型很好地解决了各个类加载器的基础类的统一问题,越是基础的类由越上层的加载器加载。基础类之所以称为基础,就是因为他们总是作为被用户代码调用的API,但如果基础类又要回调用户代码,该怎么办?
(1). 基础类回调用户类(SPI接口与SPI实现的加载问题)
前面提到的类加载器的代理模式并不能解决 Java 应用开发中会遇到的类加载器的全部问题。Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现,常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JDBC的 SPI 接口定义包含在 javax.sql包中;而这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JDBC SPI 的 Mysql所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类,如SPI中的 javax.sql.DriverManager类需要在com.mysql.jdbc.Driver的实例基础上获得和MySQL的连接对象Connection实例,比如:
static {
try {
Class.forName("com.mysql.jdbc.Driver").newInstance(); // (1)
} catch (Exception e) {
e.printStackTrace();
}
}
public static Connection getConnection(String url) throws SQLException {
return DriverManager.getConnection(url); // (2)
}12345678910
这里的实例的真正的类是继承自 javax.sql.Driver(接口),由 SPI 的实现所提供的。而问题在于:SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的;SPI 实现的 Java 类一般是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库,而且它也不能委托给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的双亲委派模式无法解决这个问题。
线程上下文类加载器正好解决了这个问题。线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 Java.lang.Thread中的方法 getContextClassLoader()和 setContextClassLoader(ClassLoader cl)用来获取和设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上已经违背了双亲委派模型的一般性原则。线程上下文类加载器在很多 SPI 的实现中都会用到。
更多关于SPI接口与SPI实现的加载问题的介绍请关注博文《违反ClassLoader双亲委派机制三部曲之首部——JDBC驱动加载》 和 《深入理解Java类加载器(二):线程上下文类加载器》。
(2). OSGi(Open Service Gateway initiative)
OSGi是以Java为技术平台的动态模块化规范,业界Java模块化标准,常用于模块热部署。所谓模块热部署是指将项目部署到Web容器后,我们可以将某些功能模块拿下来或放上去,并且不会对其他模块造成影响。模块化热部署的实现关键在于自定义类加载器机制的实现:每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bunble连同类加载器一起换掉以实现代码的热替换。 在OSGi中,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。
3). Tomcat 中的反双亲委派模型
WebappClassLoader内部重写了loadClass和findClass方法,实现了绕过“双亲委派”直接加载web应用内部的资源,当然可以通过在Context.xml文件中加上如下代码片段开启正统的“双亲委派”加载机制。关于更多Tomcat中的反双亲委派模型的内容请参见博文《违反ClassLoader双亲委派机制三部曲第二部——Tomcat类加载机制》。
<Loader delegate = "true">1

40、为什么新生代内存需要有两个Survivor区?
这个问题可以分为两个子问题:“为什么要有Survivor区?” 以及“为什么要设置两个Survivor区?”。

1). 为什么要有Survivor区
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,从而触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。由于老年代的内存空间一般是新生代的2倍,因此进行一次Full GC消耗的时间比Minor GC长得多,这样,频繁的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度。Survivor的存在意义就在于,减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
2). 为什么要有两个Survivor区
设置两个Survivor区最大的好处就是解决了碎片化。在第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
同时,我们也知道,现在大多数JVM虚拟机将新生代与老年代按照如下比例来分配:
Eden :Survivor(to,from) = 8 :2(1:1)
在这里,Eden区理所当然大一些,否则新建对象很快就导致Eden区满,进而触发Minor GC,有悖于初衷。
41、JDK 1.8相对于之前版本中HashMap中的实现的变化?
JDK 1.8 以后哈希表的添加、删除、查找、扩容方法都增加了一种节点为TreeNode的情况:
-
添加时,当桶中链表个数超过 8 时会转换成红黑树;
-
删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构;
-
查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差(O(lgn))。

更多关于HashMap在JDK 8中的实现请参见博文《Java 集合深入理解(17):HashMap 在 JDK 1.8 后新增的红黑树结构》。
42、ThreadLocal 的内存泄漏问题
ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。也就是说,ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。值得注意的是图中的虚线,表示 ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。

那么,ThreadLocal为什么会内存泄漏呢?原因是,ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
综合上面的分析,ThreadLocal 的最佳实践为:每次使用完ThreadLocal,都调用它的remove()方法,清除数据。 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。