開篇:題目答案總結並非標准,僅供參考,如果有錯誤或者更好的見解,歡迎留言討論,往期公眾號整理的一些面試題看這里:Java面試題內容聚合
事務
1、什么是事務?事務的特性(ACID)
什么是事務:事務是程序中一系列嚴密的操作,所有操作執行必須成功完成,否則在每個操作所做的更改將會被撤銷,這也是事務的原子性(要么成功,要么失敗)。
事務特性分為四個:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、持續性(Durability)簡稱ACID。
1、原子性:事務是數據庫的邏輯工作單位,事務中包含的各操作要么都做,要么都不做。
2、一致性:事務執行的結果必須是使數據庫從一個一致性狀態變到另一個一致性狀態。因此當數據庫只包含成功事務提交的結果時,就說數據庫處於一致性狀態。如果數據庫系統運行中發生故障,有些事務尚未完成就被迫中斷,這些未完成事務對數據庫所做的修改有一部分已寫入物理數據庫,這時數據庫就處於一種不正確的狀態,或者說是不一致的狀態。
3、隔離性:一個事務的執行不能其它事務干擾。即一個事務內部的操作及使用的數據對其它並發事務是隔離的,並發執行的各個事務之間不能互相干擾。
4、持久性:也稱永久性,指一個事務一旦提交,它對數據庫中的數據的改變就應該是永久性的。接下來的其它操作或故障不應該對其執行結果有任何影響。
2、事務的隔離級別有幾種,最常用的隔離級別是哪兩種?
並發過程中會出現的問題:
-
丟失更新:是不可重復讀的特殊情況。如果兩個事物都讀取同一行,然后兩個都進行寫操作,並提交,第一個事物所做的改變就會丟失。
-
臟讀:一個事務讀取到另一個事務未提交的更新數據。
-
幻讀也叫虛讀:一個事務執行兩次查詢,第二次結果集包含第一次中沒有或某些行已經被刪除的數據,造成兩次結果不一致,只是另一個事務在這兩次查詢中間插入或刪除了數據造成的。
-
不可重復讀:一個事務兩次讀取同一行的數據,結果得到不同狀態的結果,中間正好另一個事務更新了該數據,兩次結果相異,不可被信任。
事務的隔離級別有4種:
1、未提交讀(Read uncommitted)
-
定義:就是一個事務讀取到其他事務未提交的數據,是級別最低的隔離機制。
-
缺點:會產生臟讀、不可重復讀、幻讀。
2、提交讀(Read committed)
-
定義:就是一個事務讀取到其他事務提交后的數據。Oracle默認隔離級別。
-
缺點:會產生不可重復讀、幻讀。
3、可重復讀(Repeatable read)
-
定義:就是一個事務對同一份數據讀取到的相同,不在乎其他事務對數據的修改。MySQL默認的隔離級別。
-
缺點:會產生幻讀。
4、串行化(Serializable)
-
定義:事務串行化執行,隔離級別最高,犧牲了系統的並發性。
-
缺點:可以解決並發事務的所有問題。但是效率地下,消耗數據庫性能,一般不使用。
緩存
3、分布式緩存的典型應用場景?
-
頁面緩存,用來緩存Web頁面的內容片段,包括HTML、CSS 和圖片等,多應用於社交網站等。
-
應用對象緩存,緩存系統作為ORM框架的二級緩存對外提供服務,目的是減輕數據庫的負載壓力,加速應用訪問。
-
狀態緩存,緩存包括Session會話狀態及應用橫向擴展時的狀態數據等,這類數據一般是難以恢復的,對可用性要求較高,多應用於高可用集群。
-
並行處理,通常涉及大量中間計算結果需要共享。
-
事件處理,分布式緩存提供了針對事件流的連續查詢(continuous query)處理技術,滿足實時性需求。
-
極限事務處理,分布式緩存為事務型應用提供高吞吐率、低延時的解決方案,支持高並發事務請求處理,多應用於鐵路、金融服務和電信等領域。
數據庫
4、MongoDB與Mysql的區別?
兩種數據庫的區別:
-
傳統的關系型數據庫,數據是以表單為媒介進行存儲的。
-
相比較Mysql,Mongodb以一種直觀文檔的方式來完成數據的存儲。
Mongodb的鮮明特征:
-
自帶GirdFS的分布式文件系統,這也為Mongodb的部署提供了很大便利。
-
Mongodb內自建了對map-reduce運算框架的支持,雖然這種支持從功能上看還算是比較簡單的,相當於MySQL里GroupBy功能的擴展版,不過也為數據的統計帶來了方便。
-
Mongodb在啟動后將數據庫中得數據以文件映射的方式加載到內存中,如果內存資源相當豐富的話,這將極大的提高數據庫的查詢速度。
Mongodb的優勢:
-
Mongodb適合那些對數據庫具體格式不明確或者數據庫數據格式經常變化的需求模型,而且對開發者十分友好。
-
Mongodb官方就自帶一個分布式文件系統,Mongodb官方就自帶一個分布式文件系統,可以很方便的部署到服務器機群上。
Mongodb的缺陷:
-
事務關系支持薄弱。這也是所有NoSQL數據庫共同的缺陷,不過NoSQL並不是為了事務關系而設計的,具體應用還是很需求。
-
穩定性有些欠缺
-
方便開發者的同時,對運維人員提出了更高的要求。
Mongodb的應用場景:
-
表結構不明確且數據不斷變大:MongoDB是非結構化文檔數據庫,擴展字段很容易且不會影響原有數據。內容管理或者博客平台等,例如圈子系統,存儲用戶評論之類的。
-
更高的寫入負載:MongoDB側重高數據寫入的性能,而非事務安全,適合業務系統中有大量“低價值”數據的場景。本身存的就是json格式數據。例如做日志系統。
-
數據量很大或者將來會變得很大:Mysql單表數據量達到5-10G時會出現明細的性能降級,需要做數據的水平和垂直拆分、庫的拆分完成擴展,MongoDB內建了sharding、很多數據分片的特性,容易水平擴展,比較好的適應大數據量增長的需求。
-
高可用性:自帶高可用,自動主從切換(副本集):
不適用的場景:
-
MongoDB不支持事務操作,需要用到事務的應用建議不用MongoDB。
-
MongoDB目前不支持join操作,需要復雜查詢的應用也不建議使用MongoDB。
-
在帶“_id”插入數據的時候,MongoDB的插入效率其實並不高。如果想充分利用MongoDB性能的話,推薦采取不帶“_id”的插入方式,然后對相關字段作索引來查詢。
關系型數據庫和非關系型數據庫的應用場景對比:
關系型數據庫適合存儲結構化數據,如用戶的帳號、地址:
-
這些數據通常需要做結構化查詢,比如join,這時候,關系型數據庫就要勝出一籌。
-
這些數據的規模、增長的速度通常是可以預期的。
-
事務性、一致性。
NoSQL適合存儲非結構化數據,如文章、評論:
-
這些數據通常用於模糊處理,如全文搜索、機器學習。
-
這些數據是海量的,而且增長的速度是難以預期的。
-
根據數據的特點,NoSQL數據庫通常具有無限(至少接近)伸縮性。
-
按key獲取數據效率很高,但是對join或其他結構化查詢的支持就比較差。
5、Mysql索引相關問題。
1)什么是索引?
-
索引其實是一種數據結構,能夠幫助我們快速的檢索數據庫中的數據。
2)索引具體采用的哪種數據結構呢?
-
常見的MySQL主要有兩種結構:Hash索引和B+ Tree索引,通常使用的是InnoDB引擎,默認的是B+樹。
3)InnoDb內存使用機制?
Innodb體系結構如圖所示:
Innodb關於查詢效率有影響的兩個比較重要的參數分別是innodb_buffer_pool_size,innodb_read_ahead_threshold:
-
innodb_buffer_pool_size指的是Innodb緩沖池的大小,該參數的大小可通過命令指定innodb_buffer_pool_size 20G。緩沖池使用改進的LRU算法進行管理,維護一個LRU列表、一個FREE列表,FREE列表存放空閑頁,數據庫啟動時LRU列表是空的,當需要從緩沖池分頁時,首先從FREE列表查找空閑頁,有則放入LRU列表,否則LRU執行淘汰,淘汰尾部的頁分配給新頁。
-
innodb_read_ahead_threshold相對應的是數據預加載機制,innodb_read_ahead_threshold 30表示的是如果一個extent中的被順序讀取的page超過或者等於該參數變量的,Innodb將會異步的將下一個extent讀取到buffer pool中,比如該參數的值為30,那么當該extent中有30個pages被sequentially的讀取,則會觸發innodb linear預讀,將下一個extent讀到內存中;在沒有該變量之前,當訪問到extent的最后一個page的時候,Innodb會決定是否將下一個extent放入到buffer pool中;可以在Mysql服務端通過show innodb status中的Pages read ahead和evicted without access兩個值來觀察預讀的情況:Innodb_buffer_pool_read_ahead:表示通過預讀請求到buffer pool的pages;Innodb_buffer_pool_read_ahead_evicted:表示由於請求到buffer pool中沒有被訪問,而驅逐出內存的頁數。
可以看出來,Mysql的緩沖池機制是能充分利用內存且有預加載機制,在某些條件下目標數據完全在內存中,也能夠具備非常好的查詢性能。
4)B+ Tree索引和Hash索引區別?
-
哈希索引適合等值查詢,但是無法進行范圍查詢。
-
哈希索引沒辦法利用索引完成排序。
-
哈希索引不支持多列聯合索引的最左匹配規則。
-
如果有大量重復鍵值的情況下,哈希索引的效率會很低,因為存在哈希碰撞問題。
5)B+ Tree的葉子節點都可以存哪些東西嗎?
-
InnoDB的B+ Tree可能存儲的是整行數據,也有可能是主鍵的值。
6)這兩者有什么區別嗎?
-
在 InnoDB 里,索引B+ Tree的葉子節點存儲了整行數據的是主鍵索引,也被稱之為聚簇索引。而索引B+ Tree的葉子節點存儲了主鍵的值的是非主鍵索引,也被稱之為非聚簇索引。
7)聚簇索引和非聚簇索引,在查詢數據的時候有區別嗎?
-
聚簇索引查詢會更快,因為主鍵索引樹的葉子節點直接就是我們要查詢的整行數據了。而非主鍵索引的葉子節點是主鍵的值,查到主鍵的值以后,還需要再通過主鍵的值再進行一次查詢。
8)主鍵索引查詢只會查一次,而非主鍵索引需要回表查詢多次(這個過程叫做回表)。是所有情況都是這樣的嗎?非主鍵索引一定會查詢多次嗎?
覆蓋索引(covering index)指一個查詢語句的執行只用從索引中就能夠取得,不必從數據表中讀取。也可以稱之為實現了索引覆蓋。當一條查詢語句符合覆蓋索引條件時,MySQL只需要通過索引就可以返回查詢所需要的數據,這樣避免了查到索引后再返回表操作,減少I/O提高效率。
如,表covering_index_sample中有一個普通索引 idx_key1_key2(key1,key2)。當我們通過SQL語句:select key2 from covering_index_sample where key1 = 'keytest';的時候,就可以通過覆蓋索引查詢,無需回表。
9)在創建索引的時候都會考慮哪些因素呢?
一般對於查詢概率比較高,經常作為where條件的字段設置索引。
10)在創建聯合索引的時候,需要做聯合索引多個字段之間順序,這是如何選擇的呢?
在創建多列索引時,我們根據業務需求,where子句中使用最頻繁的一列放在最左邊,因為MySQL索引查詢會遵循最左前綴匹配的原則,即最左優先,在檢索數據時從聯合索引的最左邊開始匹配。
所以當我們創建一個聯合索引的時候,如(key1,key2,key3),相當於創建了(key1)、(key1,key2)和(key1,key2,key3)三個索引,這就是最左匹配原則。
11)你知道在MySQL 5.6中,對索引做了哪些優化嗎?
-
索引條件下推:“索引條件下推”,稱為 Index Condition Pushdown (ICP),這是MySQL提供的用某一個索引對一個特定的表從表中獲取元組”,注意我們這里特意強調了“一個”,這是因為這樣的索引優化不是用於多表連接而是用於單表掃描,確切地說,是單表利用索引進行掃描以獲取數據的一種方式。
-
例如有索引(key1,key2),SQL語句中
where key1 = 'XXX' and key2 like '%XXX%'
: -
如果沒有使用索引下推技術,MySQL會通過key1 = 'XXX'從存儲引擎返回對應的數據至MySQL服務端,服務端再基於key2 like 判斷是否符合條件。
-
如果使用了索引下推技術,MySQL首先返回key1='XXX'的索引,再根據key2 like 判斷索引是否符合條件,如果符合則通過索引定位數據,如果不符合則直接reject掉。有了索引下推優化,可以在有like條件查詢的情況下,減少回表次數。
12)如何知道索引是否生效?
explain顯示了MySQL如何使用索引來處理select語句以及連接表。可以幫助選擇更好的索引和寫出更優化的查詢語句。使用方法,在select語句前加上explain就可以了。
13)那什么情況下會發生明明創建了索引,但是執行的時候並沒有通過索引呢?
在一條單表查詢語句真正執行之前,MySQL的查詢優化器會找出執行該語句所有可能使用的方案,對比之后找出成本最低的方案。這個成本最低的方案就是所謂的執行計划。優化過程大致如下:
-
根據搜索條件,找出所有可能使用的索引。
-
計算全表掃描的代價。
-
計算使用不同索引執行查詢的代價。
-
對比各種執行方案的代價,找出成本最低的那一個。
14)為什么索引結構默認使用B+Tree,而不是Hash,二叉樹,紅黑樹?
-
B+tree是一種多路平衡查詢樹,節點是天然有序的,非葉子節點包含多個元素,不保存數據,只用來索引,葉子節點包含完整數據和帶有指向下一個節點的指針,形成一個有序鏈表,有助於范圍和順序查找。因為非葉子節點不保存數據,所以同樣大小的磁盤頁可以容納更多的元素,同樣能數據量的情況下,B+tree相比B-tree高度更低,因此查詢時IO會更少。
-
B-tree不管葉子節點還是非葉子節點,都會保存數據,這樣導致在非葉子節點中能保存的指針數量變少(有些資料也稱為扇出),指針少的情況下要保存大量數據,只能增加樹的高度,導致IO操作變多,查詢性能變低;
-
Hash索引底層是基於哈希表,就是以key-value存儲數據的結構,多個數據在存儲關系上是沒有任何順序關系的。只適合等值查詢,不適合范圍查詢,而且也無法利用索引完成排序,不支持聯合索引的最左匹配原則,如果有大量重復鍵值的情況下,哈希索引效率會很低,因為存在哈希碰撞。
-
二叉樹:樹的高度不均勻,不能自平衡,查找效率跟數據有關(樹的高度),並且IO代價高。
-
紅黑樹:樹的高度隨着數據量增加而增加,IO代價高。
6、如何優化MySQL?
MySQL優化大致可以分為三部分:索引的優化、SQL語句優化和表的優化
索引優化可以遵循以下幾個原則:
-
聯合索引最左前綴匹配原則
-
盡量把字段長度小的列放在聯合索引的最左側(因為字段越小,一頁存儲的數據量越大,IO性能也就越好)
-
order by 有多個列排序的,應該建立聯合索引
-
對於頻繁的查詢優先考慮使用覆蓋索引
-
前導模糊查詢不會使用索引,比如說Like '%aaa%'這種
-
負向條件不會使用索引,如!=,<>,not like,not in,not exists
-
索引應該建立在區分度比較高的字段上 一般區分度在80%以上的時候就可以建立索引,區分度可以使用 count(distinct(列名))/count(*)
-
對於where子句中經常使用的列,最好設置索引
SQL語句優化,可以通過explain查看SQL的執行計划,優化語句原則可以有:
-
在where和order by涉及的列上建立合適的索引,避免全表掃描
-
任何查詢都不要使用select * ,而是用具體的字段列表代替
-
多表連接時,盡量小表驅動大表,即小表join大表
-
用exists代替in
-
盡量避免在where字句中對字段進行函數操作
數據庫表優化
-
表字段盡可能用not null
-
字段長度固定表查詢會更快
-
將數據庫大表按照時間或者一些標志拆分成小表
-
水平拆分:將記錄散列到不同的表中,每次從分表查詢
-
垂直拆分:將表中的大字段單獨拆分到另一張表,形成一對一的關系
7、為什么任何查詢都不要使用SELECT *?
-
多出一些不用的列,這些列可能正好不在索引的范圍之內(索引的好處不多說)select * 杜絕了索引覆蓋的可能性,而索引覆蓋又是速度極快,效率極高,業界極為推薦的查詢方式。(索引覆蓋)
-
數據庫需要知道 * 等於什么 = 查數據字典會增大開銷(記錄數據庫和應用程序元數據的目錄)。
-
不需要的字段會增加數據傳輸的時間,即使 mysql 服務器和客戶端是在同一台機器上,使用的協議還是 tcp,通信也是需要額外的時間。
-
大字段,例如很長的 varchar,blob,text。准確來說,長度超過 728 字節的時候,會把超出的數據放到另外一個地方,因此讀取這條記錄會增加一次 io 操作。(mysql innodb)
-
影響數據庫自動重寫優化SQL(類似 Java 中編譯 class 時的編譯器自動優化) 。(Oracle)
-
select * 數據庫需要解析更多的 對象,字段,權限,屬性相關,在 SQL 語句復雜,硬解析較多的情況下,會對數據庫造成沉重的負擔。
-
額外的 io,內存和 cpu 的消耗,因為多取了不必要的列。
-
用 SELECT * 需謹慎,因為一旦列的個數或順序更改,就有可能程序執行失敗。
多線程
Java實現多線程有幾種方式?
有三種方式:
-
繼承Thread類,並重寫run方法。
-
實現Runnable接口,並重寫run方法。
-
實現Callable接口,並重寫run方法,並使用FutureTask包裝器。
線程的生命周期
1、新建狀態(New):新創建了一個線程對象。
2、就緒狀態(Runnable):線程對象創建后,其他線程調用了該對象的start()方法。該狀態的線程位於可運行線程池中,變得可運行,等待獲取CPU的使用權。
3、運行狀態(Running):就緒狀態的線程獲取了CPU,執行程序代碼。
4、阻塞狀態(Blocked):阻塞狀態是線程因為某種原因放棄CPU使用權,暫時停止運行。直到線程進入就緒狀態,才有機會轉到運行狀態。阻塞的情況分三種:
-
等待阻塞:運行的線程執行wait()方法,JVM會把該線程放入等待池中。(wait會釋放持有的鎖)
-
同步阻塞:運行的線程在獲取對象的同步鎖時,若該同步鎖被別的線程占用,則JVM會把該線程放入鎖池中。
-
其他阻塞:運行的線程執行sleep()或join()方法,或者發出了I/O請求時,JVM會把該線程置為阻塞狀態。當sleep()狀態超時、join()等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入就緒狀態。(注意,sleep是不會釋放持有的鎖)
5、死亡狀態(Dead):線程執行完了或者因異常退出了run()方法,該線程結束生命周期。
start()方法和run()方法的區別?
-
start()方法會使得該線程開始執行,java虛擬機會去調用該線程的run()方法。
-
通過調用Thread類的 start()方法來啟動一個線程,這時此線程處於就緒(可運行)狀態,並沒有運行,一旦得到cpu時間片,就開始執行run()方法,這里方法 run()稱為線程體,它包含了要執行的這個線程的內容,run方法運行結束,此線程隨即終止。
-
run()方法只是類的一個普通方法而已,如果直接調用run方法,程序中依然只有主線程這一個線程,其程序執行路徑還是只有一條,還是要順序執行,還是要等待run方法體執行完畢后才可繼續執行下面的代碼,這樣就沒有達到多線程的目的。
Runnable接口和Callable接口的區別?
-
Runnable接口中的run()方法的返回值是void,它做的事情只是純粹地去執行run()方法中的代碼而已。
-
Callable接口中的call()方法是有返回值的,是一個泛型,和Future、FutureTask配合可以用來獲取異步執行的結果。
-
這其實是很有用的一個特性,因為多線程相比單線程更難、更復雜的一個重要原因就是因為多線程充滿着未知性,某條線程是否執行了?某條線程執行了多久?某條線程執行的時候我們期望的數據是否已經賦值完畢?無法得知,我們能做的只是等待這條多線程的任務執行完畢而已。而Callable + Future/FutureTask卻可以獲取多線程運行的結果,可以在等待時間太長沒獲取到需要的數據的情況下取消該線程的任務,真的是非常有用。
volatile關鍵字
volatile基本介紹:volatile可以看成是synchronized的一種輕量級的實現,但volatile並不能完全代替synchronized,volatile有synchronized可見性的特性,但沒有synchronized原子性的特性。可見性即用volatile關鍵字修飾的成員變量表明該變量不存在工作線程的副本,線程每次直接都從主內存中讀取,每次讀取的都是最新的值,這也就保證了變量對其他線程的可見性。另外,使用volatile還能確保變量不能被重排序,保證了有序性。
當一個變量定義為volatile之后,它將具備兩種特性:
-
①保證此變量對所有線程的可見性:當一條線程修改了這個變量的值,新值對於其他線程可以說是可以立即得知的。Java內存模型規定了所有的變量都存儲在主內存,每條線程還有自己的工作內存,線程的工作內存保存了該線程使用到的變量在主內存的副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀取主內存中的變量。
-
②禁止指令重排序優化:
volatile boolean isOK = false; //假設以下代碼在線程A執行 A.init(); isOK=true; //假設以下代碼在線程B執行 while(!isOK){ sleep(); } B.init();
A線程在初始化的時候,B線程處於睡眠狀態,等待A線程完成初始化的時候才能夠進行自己的初始化。這里的先后關系依賴於isOK這個變量。如果沒有volatile修飾isOK這個變量,那么isOK的賦值就可能出現在A.init()之前(指令重排序,Java虛擬機的一種優化措施),此時A沒有初始化,而B的初始化就破壞了它們之前形成的那種依賴關系,可能就會出錯。
volatile使用場景:
如果正確使用volatile的話,必須依賴下以下種條件:
-
對變量的寫操作不依賴當前變量的值。
-
該變量沒有包含在其他變量的不變式中。
在以下兩種情況下都必須使用volatile:
-
狀態的改變。
-
讀多寫少的情況。
什么是線程安全?
如果你的代碼在多線程下執行和在單線程下執行永遠都能獲得一樣的結果,那么你的代碼就是線程安全的。
線程安全的級別:
-
1)不可變:像String、Integer、Long這些,都是final類型的類,任何一個線程都改變不了它們的值,要改變除非新創建一個,因此這些不可變對象不需要任何同步手段就可以直接在多線程環境下使用。
-
2)絕對線程安全:不管運行時環境如何,調用者都不需要額外的同步措施。要做到這一點通常需要付出許多額外的代價,Java中標注自己是線程安全的類,實際上絕大多數都不是線程安全的,不過絕對線程安全的類,Java中也有,比方說CopyOnWriteArrayList、CopyOnWriteArraySet。
-
3)相對線程安全:相對線程安全也就是我們通常意義上所說的線程安全,像Vector這種,add、remove方法都是原子操作,不會被打斷,但也僅限於此,如果有個線程在遍歷某個Vector、有個線程同時在add這個Vector,99%的情況下都會出現ConcurrentModificationException,也就是fail-fast機制。
-
4)線程非安全:ArrayList、LinkedList、HashMap等都是線程非安全的類。
sleep方法和wait方法有什么區別?
-
原理不同:sleep()方法是Thread類的靜態方法,是線程用來控制自身流程的,它會使此線程暫停執行一段時間,而把執行機會讓給其他線程,等到計時時間一到,此線程會自動蘇醒。而wait()方法是Object類的方法,用於線程間的通信,這個方法會使當前擁有該對象鎖的進程等待,直到其他線程用調用notify()或notifyAll()時才蘇醒過來,開發人員也可以給它指定一個時間使其自動醒來。
-
對鎖的處理機制不同:由於sleep()方法的主要作用是讓線程暫停一段時間,時間一到則自動恢復,不涉及線程間的通信,因此調用sleep()方法並不會釋放鎖。而wait()方法則不同,當調用wait()方法后,線程會釋放掉它所占用的鎖,從而使線程所在對象中的其他synchronized數據可被別的線程使用。
-
使用區域不同:wait()方法必須放在同步控制方法或者同步語句塊中使用,而sleep方法則可以放在任何地方使用。
-
sleep()方法必須捕獲異常,而wait()、notify()、notifyAll()不需要捕獲異常。在sleep的過程中,有可能被其他對象調用它的interrupt(),產生InterruptedException異常。
-
由於sleep不會釋放鎖標志,容易導致死鎖問題的發生,一般情況下,不推薦使用sleep()方法,而推薦使用wait()方法。
寫一個會導致死鎖的程序。
public class MyThread{ private static Object lock1 = new Object(); private static Object lock2 = new Object(); public static void main(String[] args) { new Thread(()->{ synchronized (lock1){ System.out.println("thread1 get lock1"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock2){ System.out.println("thread1 get lock2"); } System.out.println("thread1 end"); } }).start(); new Thread(()->{ synchronized (lock2){ System.out.println("thread2 get lock2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock1){ System.out.println("thread2 get lock1"); } System.out.println("thread2 end"); } }).start(); } }
類加載過程
1、類加載過程:加載->鏈接(驗證+准備+解析)->初始化(使用前的准備)->使用->卸載
具體過程如下:
1)加載:首先通過一個類的全限定名來獲取此類的二進制字節流;其次將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;最后在java堆中生成一個代表這個類的Class對象,作為方法區這些數據的訪問入口。總的來說就是查找並加載類的二進制數據。
2)鏈接:
驗證:確保被加載類的正確性。
准備:為類的靜態變量分配內存,並將其初始化為默認值。
解析:把類中的符號引用轉換為直接引用。
-
符號引用即用字符串符號的形式來表示引用,其實被引用的類、方法或者變量還沒有被加載到內存中。
-
直接引用則是有具體引用地址的指針,被引用的類、方法或者變量已經被加載到內存中。
直接引用可以是:
-
直接指向目標的指針。(個人理解為:指向對象,類變量和類方法的指針)
-
相對偏移量。(指向實例的變量,方法的指針)
-
一個間接定位到對象的句柄。
為什么要使用符號引用?
符號引用要轉換成直接引用才有效,這也說明直接引用的效率要比符號引用高。那為什么要用符號引用呢?這是因為類加載之前,javac會將源代碼編譯成.class文件,這個時候javac是不知道被編譯的類中所引用的類、方法或者變量他們的引用地址在哪里,所以只能用符號引用來表示,當然,符號引用是要遵循java虛擬機規范的。
還有一種情況需要用符號引用,就例如前文舉得變量的符號引用的例子,是為了邏輯清晰和代碼的可讀性。
3)為類的靜態變量賦予正確的初始值。
2、類的初始化
1)類什么時候才被初始化:
-
創建類的實例,也就是new一個對象。
-
訪問某個類或接口的靜態變量,或者對該靜態變量賦值。
-
調用類的靜態方法。
-
反射(Class.forName(“com.lyj.load”))。
-
初始化一個類的子類(會首先初始化子類的父類)。
-
JVM啟動時標明的啟動類,即文件名和類名相同的那個類。
2)類的初始化順序
-
如果這個類還沒有被加載和鏈接,那先進行加載和鏈接
-
假如這個類存在直接父類,並且這個類還沒有被初始化(注意:在一個類加載器中,類只能初始化一次),那就初始化直接的父類(不適用於接口)
-
加入類中存在初始化語句(如static變量和static塊),那就依次執行這些初始化語句。
-
總的來說,初始化順序依次是:
(靜態變量、靜態初始化塊)–>(變量、初始化塊)–> 構造器;
如果有父類,則順序是:父類的靜態變量 –> 父類的靜態代碼塊 –> 子類的靜態變量 –> 子類的靜態代碼塊 –> 父類的非靜態變量 –> 父類的非靜態代碼塊 –> 父類的構造方法 –> 子類的非靜態變量 –> 子類的非靜態代碼塊 –> 子類的構造方法。
3、類的加載
類的加載指的是將類的.class文件中的二進制數據讀入到內存中,將其放在運行時數據區的方法區內,然后在堆區創建一個這個類的java.lang.Class對象,用來封裝類在方法區類的對象。如:
類的加載的最終產品是位於堆區中的Class對象。Class對象封裝了類在方法區內的數據結構,並且向Java程序員提供了訪問方法區內的數據結構的接口。加載類的方式有以下幾種:
-
從本地系統直接加載。
-
通過網絡下載.class文件。
-
從zip,jar等歸檔文件中加載.class文件。
-
從專有數據庫中提取.class文件。
-
將Java源文件動態編譯為.class文件(服務器)。
4、加載器
JVM的類加載是通過ClassLoader及其子類來完成的,類的層次關系和加載順序可以由下圖來描述:
加載器介紹:
1)BootstrapClassLoader(啟動類加載器):
負責加載JAVA_HOME中jre/lib/rt.jar里所有的class,加載System.getProperty(“sun.boot.class.path”)所指定的路徑或jar。
2)ExtensionClassLoader(標准擴展類加載器):
負責加載java平台中擴展功能的一些jar包,包括JAVAHOME中jre/lib/rt.jar里所有的class,加載System.getProperty(“sun.boot.class.path”)所指定的路徑或jar。2)ExtensionClassLoader(標准擴展類加載器):負責加載java平台中擴展功能的一些jar包,包括JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目錄下的jar包。載System.getProperty(“java.ext.dirs”)所指定的路徑或jar。
3)AppClassLoader(系統類加載器):
負責加載classpath中指定的jar包及目錄中class。
4)CustomClassLoader(自定義加載器):
屬於應用程序根據自身需要自定義的ClassLoader,如tomcat、jboss都會根據j2ee規范自行實現。
類加載器的順序
-
加載過程中會先檢查類是否被已加載,檢查順序是自底向上,從Custom ClassLoader到BootStrap ClassLoader逐層檢查,只要某個classloader已加載就視為已加載此類,保證此類只所有ClassLoader加載一次。而加載的順序是自頂向下,也就是由上層來逐層嘗試加載此類。
-
在加載類時,每個類加載器會將加載任務上交給其父,如果其父找不到,再由自己去加載。
-
Bootstrap Loader(啟動類加載器)是最頂級的類加載器了,其父加載器為null。
5、類加載器之雙親委派模型
-
所謂的雙親委派模型指除了啟動類加載器以外,其余的加載器都有自己的父類加載器,而在工作的時候,如果一個類加載器收到加載請求,他不會馬上加載類,而是將這個請求向上傳遞給他的父加載器,看父加載器能不能加載這個類,加載的原則就是優先父加載器加載,如果父加載器加載不了,自己才能加載。
-
因為有了雙親委派模型的存在,類似Object類重復多次的問題就不會存在了,因為經過層層傳遞,加載請求最終都會被Bootstrap ClassLoader所響應。加載的Object對象也會只有一個。並且面對同一JVM進程多版本共存的問題,只要自定義一個不向上傳遞加載請求的加載器就好啦。
垃圾回收機制
Java內存區域划分
我們先來看看Java的內存區域划分情況,如下圖所示:
私有內存區的區域名和相應的特性如下表所示:
虛擬機棧中的局部變量表里面存放了三個信息:
-
各種基本數據類型(boolean、byte、char、short、int、float、long、double)。
-
對象引用(reference)。
-
returnAddress地址。
這個returnAddress和程序計數器有什么區別?前者是指示JVM的指令執行到了哪一行,后者是指你的代碼執行到哪一行。
共享內存區(接下來主要講jdk1.7)的區域名和相應的特性如下表所示:
哪些內存需要回收?
私有內存區伴隨着線程的產生而產生,一旦線程中止,私有內存區也會自動消除,因此我們在本文中討論的內存回收主要是針對共享內存區。
Java堆
新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC (但非絕對,在Parallel Scavenge收集器的收集策略里就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
新生代:剛剛新建的對象在Eden中,經歷一次Minor GC, Eden中的存活對象就被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC, Eden和S0中的存活對象會被復制送入第二塊survivor space S1。S0和Eden被清空,然后下一輪S0與S1交換角色,如此循環往復。如果對象的復制次數達到16次,該對象就被送到老年代中。
為什么新生代內存需要有兩個Sruvivor區:
先不去想為什么有兩個Survivor區,第一個問題是,設置Survivor區的意義在哪里?
如果沒有Survivor,Eden區每進行一次Minor GC,存活的對象就會被送到老年代。老年代很快被填滿,觸發Major GC(因為Major GC一般伴隨着Minor GC,也可以看做觸發了Full GC)。老年代的內存空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多。你也許會問,執行時間長有什么壞處?頻發的Full GC消耗的時間是非常可觀的,這一點會影響大型程序的執行和響應速度,更不要說某些連接會因為超時發生連接錯誤了。那我們來想想在沒有Survivor的情況下,有沒有什么解決辦法,可以避免上述情況:
顯而易見,沒有Survivor的話,上述兩種解決方案都不能從根本上解決問題。我們可以得到第一條結論:Survivor的存在意義,就是減少被送到老年代的對象,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷16次Minor GC還能在新生代中存活的對象,才會被送到老年代。
設置兩個Survivor區最大的好處就是解決了碎片化,下面我們來分析一下。為什么一個Survivor區不行?
第一部分中,我們知道了必須設置Survivor區。假設現在只有一個survivor區,我們來模擬一下流程:
剛剛新建的對象在Eden中,一旦Eden滿了,觸發一次Minor GC,Eden中的存活對象就會被移動到Survivor區。這樣繼續循環下去,下一次Eden滿了的時候,問題來了,此時進行Minor GC,Eden和Survivor各有一些存活對象,如果此時把Eden區的存活對象硬放到Survivor區,很明顯這兩部分對象所占有的內存是不連續的,也就導致了內存碎片化。
那么,順理成章的,應該建立兩塊Survivor區,剛剛新建的對象在Eden中,經歷一次Minor GC,Eden中的存活對象就會被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC,Eden和S0中的存活對象又會被復制送入第二塊survivor space S1(這個過程非常重要,因為這種復制算法保證了S1中來自S0和Eden兩部分的存活對象占用連續的內存空間,避免了碎片化的發生)。S0和Eden被清空,然后下一輪S0與S1交換角色,如此循環往復。如果對象的復制次數達到16次,該對象就會被送到老年代中。
參考文章:https://blog.csdn.net/antony9118/article/details/51425581
老年代:如果某個對象經歷了幾次垃圾回收之后還存活,就會被存放到老年代中。老年代的空間一般比新生代大。
這個流程如下圖所示:
什么時候回收?
Java並沒有給我們提供明確的代碼來標注一塊內存並將其回收。或許你會說,我們可以將相關對象設為null或者用System.gc()。然而,后者將會嚴重影響代碼的性能,因為每一次顯示調用system.gc()都會停止所有響應,去檢查內存中是否有可回收的對象,這會對程序的正常運行造成極大威脅。
另外,調用該方法並不能保障JVM立即進行垃圾回收,僅僅是通知JVM要進行垃圾回收了,具體回收與否完全由JVM決定。
生存還是死亡
可達性算法:這個算法的基本思路是通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的。
二次標記:在可達性分析算法中被判斷是對象不可達時不一定會被垃圾回收機制回收,因為要真正宣告一個對象的死亡,必須經歷兩次標記的過程。
如果發現對象不可達時,將會進行第一次標記,此時如果該對象調用了finalize()方法,那么這個對象會被放置在一個叫F-Queue的隊列之中,如果在此隊列中該對象沒有成功拯救自己(拯救自己的方法是該對象有沒有被重新引用),
那么GC就會對F-Queue隊列中的對象進行小規模的第二次標記,一旦被第二次標記的對象,將會被移除隊列並等待被GC回收,所以finalize()方法是對象逃脫死亡命運的最后一次機會。
在Java語言中,可作為GC Roots的對象包括下面幾種:
-
虛擬機棧(棧幀中的本地變量表)中引用的對象。
-
方法區中靜態屬性引用的對象。
-
方法區中常量引用的對象。
-
本地方法棧中JNI(即一般說的Native方法)引用的對象。
GC的算法
引用計數法(Reference Counting):
給對象添加一個引用計數器,每過一個引用計數器值就+1,少一個引用就-1。當它的引用變為0時,該對象就不能再被使用。它的實現簡單,但是不能解決互相循環引用的問題。
優點:
-
及時回收無效內存,實時性高。
-
垃圾回收過程中無需掛起。
-
沒有全局掃描,性能高。
缺點:
-
對象創建時需要更新引用計數器,耗費一部分時間。
-
浪費CPU資源,計數器統計需要實時進行。
-
無法解決循環引用問題,即使對象無效仍不會被回收。
標記-清除(Mark-Sweep)算法:
分為兩個階段:首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象(后續的垃圾回收算法都是基於此算法進行改進的)。
缺點:效率問題,標記和清除兩個過程的效率都不高;空間問題,會產生很多碎片。
復制算法:
將可用內存按容量划分為大小相等的兩塊,每次只用其中一塊。當這一塊用完了,就將還存活的對象復制到另外一塊上面,然后把原始空間全部回收。高效、簡單。
缺點:將內存縮小為原來的一半。
標記-整理(Mark-Compat)算法
標記過程與標記-清除算法過程一樣,但后面不是簡單的清除,而是讓所有存活的對象都向一端移動,然后直接清除掉端邊界以外的內存。
分代收集(Generational Collection)算法
新生代中,每次垃圾收集時都有大批對象死去,只有少量存活,就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。
老年代中,其存活率較高、沒有額外空間對它進行分配擔保,就應該使用“標記-整理”或“標記-清除”算法進行回收。
增量回收GC和並行回收GC這里就不做具體介紹了,有興趣的朋友可以自行了解一下。
垃圾收集器
Serial收集器:單線程收集器,表示在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。"Stop The World"。
ParNew收集器:實際就是Serial收集器的多線程版本。
-
並發(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。
-
並行(Concurrent):指用戶線程與垃圾收集線程同時執行,用戶程序在繼續運行,而垃圾收集程序運行於另一個CPU上。
Parallel Scavenge收集器:該收集器比較關注吞吐量(Throughout)(CPU用於用戶代碼的時間與CPU總消耗時間的比值),保證吞吐量在一個可控的范圍內。
CMS(Concurrent Mark Sweep)收集器:CMS收集器是一種以獲取最短回收停頓時間為目標的垃圾收集器,是基於“標記——清除”算法實現的。
其回收過程主要分為四個步驟:
-
初始標記:標記一下GC Roots能直接關聯到的對象,速度很快。
-
並發標記:進行GC Roots Tracing的過程,也就是標記不可達的對象,相對耗時。
-
重新標記:修正並發標記期間因用戶程序繼續運作導致的標記變動,速度比較快。
-
並發清除:對標記的對象進行統一回收處理,比較耗時。
由於初始標記和重新標記速度比較快,其它工作線程停頓的時間幾乎可以忽略不計,所以CMS的內存回收過程是與用戶線程一起並發執行的。初始標記和重新標記兩個步驟需要Stop the world;並發標記和並發清除兩個步驟可與用戶線程並發執行。“Stop the world”意思是垃圾收集器在進行垃圾回收時,會暫停其它所有工作線程,直到垃圾收集結束為止。
CMS的缺點:
-
對CPU資源非常敏感;也就是說當CMS開啟垃圾收集線程進行垃圾回收時,會占用部分用戶線程,如果在CPU資源緊張的情況下,會導致用戶程序的工作效率下降。
-
無法處理浮動垃圾導致又一次FULL GC的產生;由於CMS並發回收垃圾時用戶線程同時也在運行,伴隨用戶線程的運行自然會有新的垃圾產生,這部分垃圾出現在標記過程之后,CMS無法在當次收集過程中進行回收,只能在下一次GC時在進行清除。所以在CMS運行期間要確保內存中有足夠的預留空間用來存放用戶線程的產生的浮動垃圾,不允許像其它收集器一樣等到老年代區完全填滿了之后再進行收集;那么當內存預留的空間不足時就會產生又一次的FULL GC來釋放內存空間,由於是通過Serial Old收集器進行老年代的垃圾收集,所以導致停頓的時間變長了(系統有一個閾值來觸發CMS收集器的啟動,這個閾值不允許太高,太高反而導致性能降低)。
-
標記——清除算法會產生內存碎片;如果產生過多的內存碎片時,當系統虛擬機想要再分配大對象時,會找不到一塊足夠大的連續內存空間進行存儲,不得不又一次觸發FULL GC。
G1(Garbage First)收集器:G1收集器是一款成熟的商用的垃圾收集器,是基於“標記——整理”算法實現的。
其回收過程主要分為四個步驟:
-
初始標記:標記一下GC Roots能直接關聯到的對象,速度很快。
-
並發標記:進行GC Roots Tracing的過程,也就是標記不可達的對象,相對耗時。
-
最終標記:修正並發標記期間因用戶程序繼續運作導致的標記變動,速度比較快。
-
篩選回收:首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計划。
G1收集器的特點:
-
並發與並行:機型垃圾收集時可以與用戶線程並發運行。
-
分代收集:能根據對象的存活時間采取不同的收集算法進行垃圾回收。
-
不會產生內存碎片:基於標記——整理算法和復制算法保證不會產生內存空間碎片。
-
可預測的停頓:G1除了追求低停頓時間外,還能建立可預測的停頓時間模型,便於用戶的實時監控。
CMS收集器與G1收集器的區別:
-
CMS采用標記——清除算法會產生空間碎片,G1采用標記——整理算法不會產生空間碎片。
-
G1可以建立可預測的停頓時間模型,而CMS則不能。
JDK 1.8 JVM的變化
1、為什么取消方法區
-
它在啟動時固定大小,很難進行調優,並且FullGC時會移動類元信息。
-
類及方法的信息等比較難確定大小,因此對永久代的大小指定比較困難。
-
在某些場景下,如果動態加載類過多,容易造成Perm區的OOM。
-
字符串存在方法區中,容易出現性能問題和內存溢出。
-
永久代GC垃圾回收效率偏低。
2、JDK 1.8里Perm區中的所有內容中字符串常量移至堆內存,其他內容如類元信息、字段、靜態屬性、方法、常量等都移動到元空間內。
3、元空間
元空間(MetaSpace)不在堆內存上,而是直接占用的本地內存。因此元空間的大小僅受本地內存限制
也可通過參數來設定元空間的大小:
-
-XX:MetaSpaceSize 初始元空間大小
-
-XX:MaxMetaSpaceSize 最大元空間大小
除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空間容量的百分比,減少為分配空間所導致的垃圾收集。
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空間容量的百分比,減少為釋放空間所導致的垃圾收集。
元空間的特點:
-
每個加載器有專門的存儲空間。
-
不會單獨回收某個類。
-
元空間里的對象的位置是固定的。
-
如果發現某個加載器不再存貨了,會把相關的空間整個回收。
性能優化:
-
減少new對象。每次new對象之后,都要開辟新的內存空間。這些對象不被引用之后,還要回收掉。因此,如果最大限度地合理重用對象,或者使用基本數據類型替代對象,都有助於節省內存。
-
多使用局部變量,減少使用靜態變量。局部變量被創建在棧中,存取速度快。靜態變量則是存儲在堆內存中。
-
避免使用finalize,該方法會給GC增添很大的負擔。
-
如果是單線程,盡量使用非多線程安全的,因為線程安全來自於同步機制,同步機制會降低性能。例如,單線程程序,能使用HashMap,就不要使用HashTabl。同理,盡量減少使用synchronized。
-
用移位符號替代乘除號。比如:a*8應該寫作a<<3。
-
對於經常反復使用的對象使用緩存。
-
盡量使用基本類型而不是包裝類型,盡量使用一維數組而不是二維數組。
-
盡量使用final修飾符,final表示不可修改,訪問效率高。
-
單線程下(或者是針對於局部變量),字符串盡量使用StringBuilder,比StringBuffer要快。
-
盡量使用StringBuffer來連接字符串。這里需要注意的是,StringBuffer的默認緩存容量是16個字符,如果超過16,append方法調用私有的expandCapacity()方法,來保證足夠的緩存容量。因此,如果可以預設StringBuffer的容量,避免append再去擴展容量。
java自動裝箱拆箱總結
當基本類型包裝類與基本類型值進行==運算時,包裝類會自動拆箱。即比較的是基本類型值。
具體實現上,是調用了Integer.intValue()方法實現拆箱。
int a = 1; Integer b = 1; Integer c = new Integer(1); System.out.println(a == b); //true System.out.println(a == c); //true System.out.println(c == b); //false Integer a = 1; 會調用這個 Integer a = Integer.valueOf(1); Integer已經默認創建了數值【-128到127】的Integer常量池 Integer a = -128; Integer b = -128; System.out.println(a == b); //true Integer a = 128; Integer b = 128; System.out.println(a == b); //false Java的數學計算是在內存棧里操作的 c1 + c2 會進行拆箱,比較還是基本類型 int a = 0; Integer b1 = 1000; Integer c1 = new Integer(1000); Integer b2 = 0; Integer c2 = new Integer(0); System.out.println(b1 == b1 + b2); //true System.out.println(c1 == c1 + c2); //true System.out.println(b1 == b1 + a); //true System.out.println(c1 == c1 + a); //true
以上這些,答案總結並非標准,僅供參考,如果有錯誤或者更好的見解,歡迎留言討論,往期公眾號整理的一些面試題看這里:Java面試題內容聚合