1. 程式人生 > 其它 >poi寫excel空指標異常_面試官:你寫的單例模式有空指標異常,請你用Volatile改一下。我愣了五分鐘...

poi寫excel空指標異常_面試官:你寫的單例模式有空指標異常,請你用Volatile改一下。我愣了五分鐘...

技術標籤:poi寫excel空指標異常

JAVA前線

網際網路技術人思考與分享,歡迎長按關注

aa1a106a991ec7304446642534609e33.png

1 單例模式


大家對單例模式並不會陌生,當建立一個物件需要消耗比較多資源時,例如建立資料庫連線和訊息服務端等等,這時我們選擇只建立一份這種型別的物件並在程序內共享。

但是單例模式想要寫好並不容易,我們寫多個版本的單例模式看看每個版本都有什麼問題。

1.1 版本一

這個版本問題非常明顯:多個執行緒可能同時執行到語句1,而此時myConnection都為空,造成連線物件被多次建立。

publicclassMySimpleConnection{
privatestaticMySimpleConnectionmyConnection=null;

privateMySimpleConnection(){
System.out.println(Thread.currentThread().getName()+"->initconnection");
}

publicstaticMySimpleConnectiongetConnection(){
if(null==myConnection){//語句1
myConnection=newMySimpleConnection();
}
returnmyConnection;
}

publicstaticvoidmain(String[]args){
for(inti=0;i10;i++){
newThread(()->{
MySimpleConnection.getConnection();
},"threadName"+i).start();
}
}
}

執行結果可以看出連線被建立多次:

threadName1->initconnection
threadName4->initconnection
threadName3->initconnection
threadName2->initconnection
threadName0->initconnection

1.2 版本二

這個版本在getConnection方法增加了同步關鍵字,可以正確處理同步問題,程式執行正確符合預期,但是將同步關鍵詞加在方法上鎖粒度較大,可能會影響效能。

publicclassMySynchronizeConnection{
privatestaticMySynchronizeConnectionmyConnection=null;

privateMySynchronizeConnection(){
System.out.println(Thread.currentThread().getName()+"->initconnection");
}

publicstaticsynchronizedMySynchronizeConnectiongetConnection(){
if(null==myConnection){
myConnection=newMySynchronizeConnection();
}
returnmyConnection;
}

publicstaticvoidmain(String[]args){
for(inti=0;i10;i++){
newThread(()->{
MySynchronizeConnection.getConnection();
},"threadName"+i).start();
}
}
}

執行結果正確符合預期:

threadName0->initconnection

1.3 版本三

這個版本採用DCL(Double Check lock)雙重檢查鎖,縮小了同步鎖粒度,效能會有所提升。

publicclassMyDCLConnection{
privatestaticMyDCLConnectionmyConnection=null;

privateMyDCLConnection(){
System.out.println(Thread.currentThread().getName()+"->initconnection");
}

publicstaticMyDCLConnectiongetConnection(){
if(null==myConnection){
synchronized(MyDCLConnection.class){
if(null==myConnection){
myConnection=newMyDCLConnection();
}
}
}
returnmyConnection;
}

publicstaticvoidmain(String[]args){
for(inti=0;i10;i++){
newThread(()->{
MyDCLConnection.getConnection();
},"threadName"+i).start();
}
}
}

這段程式碼看似沒有問題了,但是其實有一個嚴重問題:這段程式碼有可能引發空指標異常,也就是呼叫getConnection方法會拿到一個空物件。

你可能會說不對,我們不是判斷了當連線不為空時才獲取連線嗎?怎麼會拿到一個空物件呢?這就引出我們下一個話題:指令重排。

2 指令重排


在JVM編譯程式碼時或者CPU執行JVM位元組碼時,為了提升效能可能對程式碼進行指令重排,也就是說程式碼執行順序不一定是程式碼編寫順序。

指令重排目的是為了在不改變程式執行結果的前提下,優化程式執行效率,其中不改變執行結果是指在單執行緒場景下。

我們分析一個指令重排例項。

publicvoidtest(){
inta=1;//語句1
intb=2;//語句2
a=a+1;//語句3
b=b*2;//語句4
}

這段程式碼執行順序可能如下:

1234
1243
1324
2134
2143
2413

我們思考一下語句3和語句4會不會第一個執行?答案是不會。在進行指令重排時必須要考慮資料依賴性。

語句3依賴語句1,語句4依賴語句2,所以語句3和語句4不會第一個執行。這也告訴我們如果語句之間沒有依賴關係就可能發生指令重排。

指令重排在多執行緒場景下會產生什麼問題呢?我們分析一個多執行緒指令重排例項。

publicclassMyTest{
inta=0;
booleanb=false;

publicvoidmethod1(){
a=1000;//語句1
b=true;//語句2
}

publicvoidmethod2(){
if(b){
a=a+1;//語句3
System.out.println(a);
}
}

publicstaticvoidmain(String[]args){
for(inti=0;i10000;i++){
MyTesttest=newMyTest();
newThread(()->test.method1()).start();
newThread(()->test.method2()).start();
}
}
}

我們思考一下a最終輸出值是多少?答案是有可能是1或者1001。

  • 由於變數a和b不存在資料依賴關係,所以經過指令重排,語句2可能先於語句1執行
  • 執行完語句2後可能還沒有執行語句1,method2搶到執行機會執行語句3,這時執行結果等於1
  • 如果指令不重排,執行結果等於1001

所以在多執行緒環境,執行結果具有不確定性是指令重排可能帶來的問題。

3 回到問題


再回到第一章節的問題:為什麼會出現空指標異常?我們分析這一段程式碼。
publicstaticMyDCLConnectiongetConnection(){
if(null==myConnection){//語句1
synchronized(MyDCLConnection.class){
if(null==myConnection){
myConnection=newMyDCLConnection();//語句2
}
}
}
returnmyConnection;//語句3
}

我們重點分析語句2,new操作在更細的層面分為以下三個步驟:

(A)分配新物件記憶體
(B)呼叫類構造器初始化成員變數
(C)instance被賦為指向新物件的引用

經過指令重排可能形成以下新順序:

(A)分配新物件記憶體
(B)instance被賦為指向新物件的引用
(C)呼叫類構造器初始化成員變數

根據新順序我們分析一種異常場景:

  • 執行緒1執行到語句2,執行到instance被賦為指向新物件引用這個步驟,還沒有進行初始化物件
  • 此時執行緒2執行到語句1,由於instance已經被賦為指向新物件的引用,myConnection已經不等於null,所以可以執行到語句3
  • 但是語句3返回的是沒有進行初始化的物件,所以使用這個物件就會丟擲空指標異常

4 Volatile


上述問題應該如何解決呢?本章節我們來談一談Volatile關鍵字。Volatile是JVM提供的輕量級同步機制,具有以下特性:
  • 保證可見性
  • 不保證原子性
  • 保證有序性(禁止指令重排)

其中保證可見性和不保證原子性不在本文進行討論,本文我們分析Volatile如何保證有序性。

Volatile禁止指令重排原理是使用了記憶體屏障。記憶體屏障是一種CPU指令,它使CPU或者編譯器對屏障指令之前和之後發出的記憶體操作執行一個排序約束。通過插入記憶體屏障指令,禁止在記憶體屏障指令前後的指令進行重排序。記憶體屏障有如下四種類型:

  • LoadLoad
  • StoreStore
  • LoadStore
  • StoreLoad

這樣說有一些抽象,我們結合程式碼進行分析,還是使用上文程式碼例項,只是不同的是這一次我們新增了Volatile關鍵字。

publicclassMyTest{
inta=0;
volatilebooleanb=false;

publicvoidmethod1(){
a=1000;//語句1
b=true;//語句2
}

publicvoidmethod2(){
if(b){
a=a+1;//語句3
System.out.println(a);
}
}

publicstaticvoidmain(String[]args){
for(inti=0;i10000;i++){
MyTesttest=newMyTest();
newThread(()->test.method1()).start();
newThread(()->test.method2()).start();
}
}
}

我們將b變數宣告為Volatile,那麼在為b賦值即Volatile寫前後會加上如下屏障,從而保證了語句1和語句2執行順序不會重排。

volatilebooleanb=false;
publicvoidmethod1(){
a=1000;//語句1
StoreStore屏障
b=true;//語句2
StoreLoad屏障
}

在JDK5之後Volatile還可以保證物件構造是有序的,也就是說new操作如下步驟可以保證有序,這就為我們解決DCL空指標異常提供了思路。

(A)分配新物件記憶體
(B)呼叫類構造器初始化成員變數
(C)instance被賦為指向新物件的引用

5 解決方案

經過上述分析我們可以使用Volatile解決單例DCL空指標異常。

publicclassMyVolatileConnection{
privatestaticvolatileMyVolatileConnectionmyConnection=null;

privateMyVolatileConnection(){
System.out.println(Thread.currentThread().getName()+"->initconnection");
}

publicstaticMyVolatileConnectiongetConnection(){
if(null==myConnection){
synchronized(MyVolatileConnection.class){
if(null==myConnection){
myConnection=newMyVolatileConnection();
}
}
}
returnmyConnection;
}

publicstaticvoidmain(String[]args){
for(inti=0;i10;i++){
newThread(()->{
MyVolatileConnection.getConnection();
},"threadName"+i).start();
}
}
}

程式碼改動並不大隻需在宣告MyConnection變數處加上Volatile關鍵字。

本文我們從單例模式的一個問題出發,一步步分析到Volatile關鍵字原理並最終解決單例模式DCL空指標問題,希望本文對大家有所幫助。

JAVA前線

網際網路技術人思考與分享,歡迎長按關注

aa1a106a991ec7304446642534609e33.png