我之前寫過兩篇關於優化相關的問題:《聊聊sql優化的15個小技巧》和《聊聊介面效能優化的11個小技巧》,發表之後,在全網受到廣大網友的好評。閱讀量和點贊率都很高,說明了這類文章的價值。
今天接著優化這個話題,我們一起聊聊Java中程式碼優化的30個小技巧,希望會對你有所幫助。
不知道你有沒有拼接過字串,特別是那種有多個引數,字串比較長的情況。
比如現在有個需求:要用get請求呼叫第三方介面,url後需要拼接多個引數。
以前我們的請求地址是這樣拼接的:
String url = "http://susan.sc.cn?userName="+userName+"&age="+age+"&address="+address+"&sex="+sex+"&roledId="+roleId;
字串使用+
號拼接,非常容易出錯。
後面優化了一下,改為使用StringBuilder
拼接字串:
StringBuilder urlBuilder = new StringBuilder("http://susan.sc.cn?");
urlBuilder.append("userName=")
.append(userName)
.append("&age=")
.append(age)
.append("&address=")
.append(address)
.append("&sex=")
.append(sex)
.append("&roledId=")
.append(roledId);
程式碼優化之後,稍微直觀點。
但還是看起來比較彆扭。
這時可以使用String.format
方法優化:
String requestUrl = "http://susan.sc.cn?userName=%s&age=%s&address=%s&sex=%s&roledId=%s";
String url = String.format(requestUrl,userName,age,address,sex,roledId);
程式碼的可讀性,一下子提升了很多。
我們平常可以使用String.format
方法拼接url請求引數,紀錄檔列印等字串。
但不建議在for迴圈中用它拼接字串,因為它的執行效率,比使用+號拼接字串,或者使用StringBuilder拼接字串都要慢一些。
IO流
想必大家都使用得比較多,我們經常需要把資料寫入
某個檔案,或者從某個檔案中讀取
資料到記憶體
中,甚至還有可能把檔案a,從目錄b,複製
到目錄c下等。
JDK給我們提供了非常豐富的API,可以去操作IO流。
例如:
public class IoTest1 {
public static void main(String[] args) {
FileInputStream fis = null;
FileOutputStream fos = null;
try {
File srcFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/1.txt");
File destFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/2.txt");
fis = new FileInputStream(srcFile);
fos = new FileOutputStream(destFile);
int len;
while ((len = fis.read()) != -1) {
fos.write(len);
}
fos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (fos != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (fis != null) {
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
這個例子主要的功能,是將1.txt檔案中的內容複製到2.txt檔案中。這例子使用普通的IO流從功能的角度來說,也能滿足需求,但效能卻不太好。
因為這個例子中,從1.txt檔案中讀一個位元組的資料,就會馬上寫入2.txt檔案中,需要非常頻繁的讀寫檔案。
優化:
public class IoTest {
public static void main(String[] args) {
BufferedInputStream bis = null;
BufferedOutputStream bos = null;
FileInputStream fis = null;
FileOutputStream fos = null;
try {
File srcFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/1.txt");
File destFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/2.txt");
fis = new FileInputStream(srcFile);
fos = new FileOutputStream(destFile);
bis = new BufferedInputStream(fis);
bos = new BufferedOutputStream(fos);
byte[] buffer = new byte[1024];
int len;
while ((len = bis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
bos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (bos != null) {
bos.close();
}
if (fos != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (bis != null) {
bis.close();
}
if (fis != null) {
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
這個例子使用BufferedInputStream
和BufferedOutputStream
建立了可緩衝
的輸入輸出流。
最關鍵的地方是定義了一個buffer位元組陣列,把從1.txt檔案中讀取的資料臨時儲存起來,後面再把該buffer位元組陣列的資料,一次性批次寫入到2.txt中。
這樣做的好處是,減少了讀寫檔案的次數,而我們都知道讀寫檔案是非常耗時的操作。也就是說使用可快取的輸入輸出流,可以提升IO的效能,特別是遇到檔案非常大時,效率會得到顯著提升。
在我們日常開發中,迴圈遍歷集合是必不可少的操作。
但如果迴圈層級比較深,迴圈中套迴圈,可能會影響程式碼的執行效率。
反例
:
for(User user: userList) {
for(Role role: roleList) {
if(user.getRoleId().equals(role.getId())) {
user.setRoleName(role.getName());
}
}
}
這個例子中有兩層迴圈,如果userList和roleList資料比較多的話,需要回圈遍歷很多次,才能獲取我們所需要的資料,非常消耗cpu資源。
正例
:
Map<Long, List<Role>> roleMap = roleList.stream().collect(Collectors.groupingBy(Role::getId));
for (User user : userList) {
List<Role> roles = roleMap.get(user.getRoleId());
if(CollectionUtils.isNotEmpty(roles)) {
user.setRoleName(roles.get(0).getName());
}
}
減少迴圈次數,最簡單的辦法是,把第二層迴圈的集合變成map
,這樣可以直接通過key
,獲取想要的value
資料。
雖說map的key存在hash衝突
的情況,但遍歷存放資料的連結串列
或者紅黑樹
的時間複雜度
,比遍歷整個list集合要小很多。
在我們日常開發中,可能經常存取資源
,比如:獲取資料庫連線,讀取檔案等。
我們以獲取資料庫連線為例。
反例
:
//1. 載入驅動類
Class.forName("com.mysql.jdbc.Driver");
//2. 建立連線
Connection connection = DriverManager.getConnection("jdbc:mysql//localhost:3306/db?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8","root","123456");
//3.編寫sql
String sql ="select * from user";
//4.建立PreparedStatement
PreparedStatement pstmt = conn.prepareStatement(sql);
//5.獲取查詢結果
ResultSet rs = pstmt.execteQuery();
while(rs.next()){
int id = rs.getInt("id");
String name = rs.getString("name");
}
上面這段程式碼可以正常執行,但卻犯了一個很大的錯誤,即:ResultSet、PreparedStatement和Connection物件的資源,使用完之後,沒有關閉。
我們都知道,資料庫連線是非常寶貴的資源。我們不可能一直建立連線,並且用完之後,也不回收,白白浪費資料庫資源。
正例
:
//1. 載入驅動類
Class.forName("com.mysql.jdbc.Driver");
Connection connection = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
//2. 建立連線
connection = DriverManager.getConnection("jdbc:mysql//localhost:3306/db?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8","root","123456");
//3.編寫sql
String sql ="select * from user";
//4.建立PreparedStatement
pstmt = conn.prepareStatement(sql);
//5.獲取查詢結果
rs = pstmt.execteQuery();
while(rs.next()){
int id = rs.getInt("id");
String name = rs.getString("name");
}
} catch(Exception e) {
log.error(e.getMessage(),e);
} finally {
if(rs != null) {
rs.close();
}
if(pstmt != null) {
pstmt.close();
}
if(connection != null) {
connection.close();
}
}
這個例子中,無論是ResultSet,或者PreparedStatement,還是Connection物件,使用完之後,都會呼叫close
方法關閉資源。
在這裡溫馨提醒一句:ResultSet,或者PreparedStatement,還是Connection物件,這三者關閉資源的順序不能反了,不然可能會出現異常。
我們都知道,從資料庫查資料,首先要連線資料庫,獲取Connection
資源。
想讓程式多執行緒執行,需要使用Thread
類建立執行緒,執行緒也是一種資源。
通常一次資料庫操作的過程是這樣的:
而建立連線和關閉連線,是非常耗時的操作,建立連線需要同時會建立一些資源,關閉連線時,需要回收那些資源。
如果使用者的每一次資料庫請求,程式都都需要去建立連線和關閉連線的話,可能會浪費大量的時間。
此外,可能會導致資料庫連線過多。
我們都知道資料庫的最大連線數
是有限的,以mysql為例,最大連線數是:100
,不過可以通過引數調整這個數量。
如果使用者請求的連線數超過最大連線數,就會報:too many connections
異常。如果有新的請求過來,會發現資料庫變得不可用。
這時可以通過命令:
show variables like max_connections
檢視最大連線數。
然後通過命令:
set GLOBAL max_connections=1000
手動修改最大連線數。
這種做法只能暫時緩解問題,不是一個好的方案,無法從根本上解決問題。
最大的問題是:資料庫連線數可以無限增長,不受控制。
這時我們可以使用資料庫連線池
。
目前Java開源的資料庫連線池有:
目前用的最多的資料庫連線池是:Druid
。
我們都知道通過反射
建立物件範例,比使用new
關鍵字要慢很多。
由此,不太建議在使用者請求過來時,每次都通過反射實時
建立範例。
有時候,為了程式碼的靈活性,又不得不用反射建立範例,這時該怎麼辦呢?
答:加快取
。
其實spring中就使用了大量的反射,我們以支付方法為例。
根據前端傳入不同的支付code,動態找到對應的支付方法,發起支付。
我們先定義一個註解。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PayCode {
String value();
String name();
}
在所有的支付類上都加上該註解
@PayCode(value = "alia", name = "支付寶支付")
@Service
public class AliaPay implements IPay {
@Override
public void pay() {
System.out.println("===發起支付寶支付===");
}
}
@PayCode(value = "weixin", name = "微信支付")
@Service
public class WeixinPay implements IPay {
@Override
public void pay() {
System.out.println("===發起微信支付===");
}
}
@PayCode(value = "jingdong", name = "京東支付")
@Service
public class JingDongPay implements IPay {
@Override
public void pay() {
System.out.println("===發起京東支付===");
}
}
然後增加最關鍵的類:
@Service
public class PayService2 implements ApplicationListener<ContextRefreshedEvent> {
private static Map<String, IPay> payMap = null;
@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext();
Map<String, Object> beansWithAnnotation = applicationContext.getBeansWithAnnotation(PayCode.class);
if (beansWithAnnotation != null) {
payMap = new HashMap<>();
beansWithAnnotation.forEach((key, value) ->{
String bizType = value.getClass().getAnnotation(PayCode.class).value();
payMap.put(bizType, (IPay) value);
});
}
}
public void pay(String code) {
payMap.get(code).pay();
}
}
PayService2類實現了ApplicationListener
介面,這樣在onApplicationEvent方法
中,就可以拿到ApplicationContext
的範例。這一步,其實是在spring容器啟動的時候,spring通過反射我們處理好了。
我們再獲取打了PayCode註解的類,放到一個map
中,map中的key
就是PayCode註解中定義的value,跟code引數一致,value是支付類的範例。
這樣,每次就可以每次直接通過code獲取支付類範例,而不用if...else判斷了。如果要加新的支付方法,只需在支付類上面打上PayCode註解定義一個新的code即可。
注意:這種方式的code可以沒有業務含義,可以是純數位,只要不重複就行。
很多時候,我們需要在某個介面中,呼叫其他服務的介面。
比如有這樣的業務場景:
在使用者資訊查詢介面中需要返回:使用者名稱稱、性別、等級、頭像、積分、成長值等資訊。
而使用者名稱稱、性別、等級、頭像在使用者服務中,積分在積分服務中,成長值在成長值服務中。為了彙總這些資料統一返回,需要另外提供一個對外介面服務。
於是,使用者資訊查詢介面需要呼叫使用者查詢介面、積分查詢介面 和 成長值查詢介面,然後彙總資料統一返回。
呼叫過程如下圖所示:
呼叫遠端介面總耗時 530ms = 200ms + 150ms + 180ms
顯然這種序列呼叫遠端介面效能是非常不好的,呼叫遠端介面總的耗時為所有的遠端介面耗時之和。
那麼如何優化遠端介面效能呢?
上面說到,既然序列呼叫多個遠端介面效能很差,為什麼不改成並行呢?
如下圖所示:
呼叫遠端介面總耗時 200ms = 200ms(即耗時最長的那次遠端介面呼叫)
在java8之前可以通過實現Callable
介面,獲取執行緒返回結果。
java8以後通過CompleteFuture
類實現該功能。我們這裡以CompleteFuture為例:
public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
final UserInfo userInfo = new UserInfo();
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
getRemoteUserAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
getRemoteBonusAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
getRemoteGrowthAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
userFuture.get();
bonusFuture.get();
growthFuture.get();
return userInfo;
}
溫馨提醒一下,這兩種方式別忘了使用執行緒池。範例中我用到了executor,表示自定義的執行緒池,為了防止高並行場景下,出現執行緒過多的問題。
有時候,建立物件是一個非常耗時的操作,特別是在該物件的建立過程中,還需要建立很多其他的物件時。
我們以單例模式為例。
在介紹單例模式的時候,必須要先介紹它的兩種非常著名的實現方式:餓漢模式
和 懶漢模式
。
範例在初始化的時候就已經建好了,不管你有沒有用到,先建好了再說。具體程式碼如下:
public class SimpleSingleton {
//持有自己類的參照
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
//私有的構造方法
private SimpleSingleton() {
}
//對外提供獲取範例的靜態方法
public static SimpleSingleton getInstance() {
return INSTANCE;
}
}
使用餓漢模式的好處是:沒有執行緒安全的問題
,但帶來的壞處也很明顯。
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
一開始就範例化物件了,如果範例化過程非常耗時,並且最後這個物件沒有被使用,不是白白造成資源浪費嗎?
還真是啊。
這個時候你也許會想到,不用提前範例化物件,在真正使用的時候再範例化不就可以了?
這就是我接下來要介紹的:懶漢模式
。
顧名思義就是範例在用到的時候才去建立,「比較懶」,用的時候才去檢查有沒有範例,如果有則返回,沒有則新建。具體程式碼如下:
public class SimpleSingleton2 {
private static SimpleSingleton2 INSTANCE;
private SimpleSingleton2() {
}
public static SimpleSingleton2 getInstance() {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton2();
}
return INSTANCE;
}
}
範例中的INSTANCE物件一開始是空的,在呼叫getInstance方法才會真正範例化。
懶漢模式相對於餓漢模式,沒有提前範例化物件,在真正使用的時候再範例化,在範例化物件的階段效率更高一些。
除了單例模式之外,懶載入的思想,使用比較多的可能是:
我們在實際專案開發中,需要經常使用集合,比如:ArrayList、HashMap等。
但有個問題:你在初始化集合時指定了大小的嗎?
反例
:
public class Test2 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
long time1 = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list.add(i);
}
System.out.println(System.currentTimeMillis() - time1);
}
}
執行時間:
12
如果在初始化集合時指定了大小。
正例
:
public class Test2 {
public static void main(String[] args) {
List<Integer> list2 = new ArrayList<>(100000);
long time2 = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list2.add(i);
}
System.out.println(System.currentTimeMillis() - time2);
}
}
執行時間:
6
我們驚奇的發現,在建立集合時指定了大小,比沒有指定大小,新增10萬個元素的效率提升了一倍。
如果你看過ArrayList
原始碼,你就會發現它的預設大小是10
,如果新增元素超過了一定的閥值,會按1.5
倍的大小擴容。
你想想,如果裝10萬條資料,需要擴容多少次呀?而每次擴容都需要不停的複製元素,從老集合複製到新集合中,需要浪費多少時間呀。
以前我們在開發介面時,如果出現異常
,為了給使用者一個更友好的提示,例如:
@RequestMapping("/test")
@RestController
public class TestController {
@GetMapping("/add")
public String add() {
int a = 10 / 0;
return "成功";
}
}
如果不做任何處理,當我們請求add介面時,執行結果直接報錯:
what?使用者能直接看到錯誤資訊?
這種互動方式給使用者的體驗非常差,為了解決這個問題,我們通常會在介面中捕獲異常:
@GetMapping("/add")
public String add() {
String result = "成功";
try {
int a = 10 / 0;
} catch (Exception e) {
result = "資料異常";
}
return result;
}
介面改造後,出現異常時會提示:「資料異常」,對使用者來說更友好。
看起來挺不錯的,但是有問題。。。
如果只是一個介面還好,但是如果專案中有成百上千個介面,都要加上異常捕獲程式碼嗎?
答案是否定的,這時全域性例外處理就派上用場了:RestControllerAdvice
。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public String handleException(Exception e) {
if (e instanceof ArithmeticException) {
return "資料異常";
}
if (e instanceof Exception) {
return "伺服器內部異常";
}
retur nnull;
}
}
只需在handleException
方法中處理異常情況,業務介面中可以放心使用,不再需要捕獲異常(有人統一處理了)。真是爽歪歪。
如果你讀過JDK的原始碼,比如:ThreadLocal
、HashMap
等類,你就會發現,它們的底層都用了位運算
。
為什麼開發JDK的大神們,都喜歡用位運算?
答:因為位運算的效率更高。
在ThreadLocal的get、set、remove方法中都有這樣一行程式碼:
int i = key.threadLocalHashCode & (len-1);
通過key的hashCode值,與
陣列的長度減1。其中key就是ThreadLocal物件,與
陣列的長度減1,相當於除以陣列的長度減1,然後取模
。
這是一種hash演演算法。
接下來給大家舉個例子:假設len=16,key.threadLocalHashCode=31,
於是: int i = 31 & 15 = 15
相當於:int i = 31 % 16 = 15
計算的結果是一樣的,但是使用與運算
效率跟高一些。
為什麼與運算效率更高?
答:因為ThreadLocal的初始大小是16
,每次都是按2
倍擴容,陣列的大小其實一直都是2的n次方。
這種資料有個規律就是高位是0,低位都是1。在做與運算時,可以不用考慮高位,因為與運算的結果必定是0。只需考慮低位的與運算,所以效率更高。
在Java的龐大體系中,其實有很多不錯的小工具,也就是我們平常說的:輪子
。
如果在我們的日常工作當中,能夠將這些輪子使用者,再配合一下idea的快捷鍵,可以極大得提升我們的開發效率。
如果你引入com.google.guava
的pom檔案,會獲得很多好用的小工具。這裡推薦一款com.google.common.collect
包下的集合工具:Lists
。
它是在太好用了,讓我愛不釋手。
如果你想將一個大集合
分成若干個小集合
。
之前我們是這樣做的:
List<Integer> list = Lists.newArrayList(1, 2, 3, 4, 5);
List<List<Integer>> partitionList = Lists.newArrayList();
int size = 0;
List<Integer> dataList = Lists.newArrayList();
for(Integer data : list) {
if(size >= 2) {
dataList = Lists.newArrayList();
size = 0;
}
size++;
dataList.add(data);
}
將list按size=2分成多個小集合,上面的程式碼看起來比較麻煩。
如果使用Lists
的partition
方法,可以這樣寫程式碼:
List<Integer> list = Lists.newArrayList(1, 2, 3, 4, 5);
List<List<Integer>> partitionList = Lists.partition(list, 2);
System.out.println(partitionList);
執行結果:
[[1, 2], [3, 4], [5]]
這個例子中,list有5條資料,我將list集合按大小為2,分成了3頁,即變成3個小集合。
這個是我最喜歡的方法之一,經常在專案中使用。
比如有個需求:現在有5000個id,需要呼叫批次使用者查詢介面,查出使用者資料。但如果你直接查5000個使用者,單次介面響應時間可能會非常慢。如果改成分頁處理,每次只查500個使用者,非同步呼叫10次介面,就不會有單次介面響應慢的問題。
如果你瞭解更多非常有用的第三方工具類的話,可以看看我的另一篇文章《吐血推薦17個提升開發效率的「輪子」》。
在某些業務場景中,為了防止多個執行緒並行修改某個共用資料,造成資料異常。
為了解決並行場景下,多個執行緒同時修改資料,造成資料不一致的情況。通常情況下,我們會:加鎖
。
但如果鎖加得不好,導致鎖的粒度太粗
,也會非常影響介面效能。
在java中提供了synchronized
關鍵字給我們的程式碼加鎖。
通常有兩種寫法:在方法上加鎖
和 在程式碼塊上加鎖
。
先看看如何在方法上加鎖:
public synchronized doSave(String fileUrl) {
mkdir();
uploadFile(fileUrl);
sendMessage(fileUrl);
}
這裡加鎖的目的是為了防止並行的情況下,建立了相同的目錄,第二次會建立失敗,影響業務功能。
但這種直接在方法上加鎖,鎖的粒度有點粗。因為doSave方法中的上傳檔案和發訊息方法,是不需要加鎖的。只有建立目錄方法,才需要加鎖。
我們都知道檔案上傳操作是非常耗時的,如果將整個方法加鎖,那麼需要等到整個方法執行完之後才能釋放鎖。顯然,這會導致該方法的效能很差,變得得不償失。
這時,我們可以改成在程式碼塊上加鎖了,具體程式碼如下:
public void doSave(String path,String fileUrl) {
synchronized(this) {
if(!exists(path)) {
mkdir(path);
}
}
uploadFile(fileUrl);
sendMessage(fileUrl);
}
這樣改造之後,鎖的粒度一下子變小了,只有並行建立目錄功能才加了鎖。而建立目錄是一個非常快的操作,即使加鎖對介面的效能影響也不大。
最重要的是,其他的上傳檔案和傳送訊息功能,任然可以並行執行。
在Java中保證執行緒安全的技術有很多,可以使用synchroized
、Lock
等關鍵字給程式碼塊加鎖
。
但是它們有個共同的特點,就是加鎖會對程式碼的效能有一定的損耗。
其實,在jdk中還提供了另外一種思想即:用空間換時間
。
沒錯,使用ThreadLocal
類就是對這種思想的一種具體體現。
ThreadLocal為每個使用變數的執行緒提供了一個獨立的變數副本,這樣每一個執行緒都能獨立地改變自己的副本,而不會影響其它執行緒所對應的副本。
ThreadLocal的用法大致是這樣的:
public class CurrentUser {
private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
public static void set(UserInfo userInfo) {
THREA_LOCAL.set(userInfo);
}
public static UserInfo get() {
THREA_LOCAL.get();
}
public static void remove() {
THREA_LOCAL.remove();
}
}
public void doSamething(UserDto userDto) {
UserInfo userInfo = convert(userDto);
CurrentUser.set(userInfo);
...
//業務程式碼
UserInfo userInfo = CurrentUser.get();
...
}
在業務程式碼的第一行,將userInfo物件設定到CurrentUser,這樣在業務程式碼中,就能通過CurrentUser.get()獲取到剛剛設定的userInfo物件。特別是對業務程式碼呼叫層級比較深的情況,這種用法非常有用,可以減少很多不必要傳參。
但在高並行的場景下,這段程式碼有問題,只往ThreadLocal存資料,資料用完之後並沒有及時清理。
ThreadLocal即使使用了WeakReference
(弱參照)也可能會存在記憶體洩露
問題,因為 entry物件中只把key(即threadLocal物件)設定成了弱參照,但是value值沒有。
那麼,如何解決這個問題呢?
public void doSamething(UserDto userDto) {
UserInfo userInfo = convert(userDto);
try{
CurrentUser.set(userInfo);
...
//業務程式碼
UserInfo userInfo = CurrentUser.get();
...
} finally {
CurrentUser.remove();
}
}
需要在finally
程式碼塊中,呼叫remove
方法清理沒用的資料。
不知道你在專案中有沒有見過,有些同事對Integer
型別的兩個引數使用==
號比較是否相等?
反正我見過的,那麼這種用法對嗎?
我的回答是看具體場景,不能說一定對,或不對。
有些狀態列位,比如:orderStatus有:-1(未下單),0(已下單),1(已支付),2(已完成),3(取消),5種狀態。
這時如果用==判斷是否相等:
Integer orderStatus1 = new Integer(1);
Integer orderStatus2 = new Integer(1);
System.out.println(orderStatus1 == orderStatus2);
返回結果會是true嗎?
答案:是false。
有些同學可能會反駁,Integer中不是有範圍是:-128-127
的快取嗎?
為什麼是false?
先看看Integer的構造方法:
它其實並沒有用到快取
。
那麼快取是在哪裡用的?
答案在valueOf
方法中:
如果上面的判斷改成這樣:
String orderStatus1 = new String("1");
String orderStatus2 = new String("1");
System.out.println(Integer.valueOf(orderStatus1) == Integer.valueOf(orderStatus2));
返回結果會是true嗎?
答案:還真是true。
我們要養成良好編碼習慣,儘量少用==判斷兩個Integer型別資料是否相等,只有在上述非常特殊的場景下才相等。
而應該改成使用equals
方法判斷:
Integer orderStatus1 = new Integer(1);
Integer orderStatus2 = new Integer(1);
System.out.println(orderStatus1.equals(orderStatus2));
執行結果為true。
很多時候,我們在日常開發中,需要建立集合。比如:為了效能考慮,從資料庫查詢某張表的所有資料,一次性載入到記憶體的某個集合中,然後做業務邏輯處理。
例如:
List<User> userList = userMapper.getAllUser();
for(User user:userList) {
doSamething();
}
從資料庫一次性查詢出所有使用者,然後在迴圈中,對每個使用者進行業務邏輯處理。
如果使用者表
的資料量非常多時,這樣userList集合會很大,可能直接導致記憶體不足,而使整個應用掛掉。
針對這種情況,必須做分頁處理
。
例如:
private static final int PAGE_SIZE = 500;
int currentPage = 1;
RequestPage page = new RequestPage();
page.setPageNo(currentPage);
page.setPageSize(PAGE_SIZE);
Page<User> pageUser = userMapper.search(page);
while(pageUser.getPageCount() >= currentPage) {
for(User user:pageUser.getData()) {
doSamething();
}
page.setPageNo(++currentPage);
pageUser = userMapper.search(page);
}
通過上面的分頁改造之後,每次從資料庫中只查詢500
條記錄,儲存到userList集合中,這樣userList不會佔用太多的記憶體。
這裡特別說明一下,如果你查詢的表中的資料量本來就很少,一次性儲存到記憶體中,也不會佔用太多記憶體,這種情況也可以不做分頁處理。
此外,還有中特殊的情況,即表中的記錄數並算不多,但每一條記錄,都有很多欄位,單條記錄就佔用很多記憶體空間,這時也需要做分頁處理,不然也會有問題。
整體的原則是要儘量避免建立大集合,導致記憶體不足的問題,但是具體多大才算大集合。目前沒有一個唯一的衡量標準,需要結合實際的業務場景進行單獨分析。
在我們建的表中,有很多狀態列位,比如:訂單狀態、禁用狀態、刪除狀態等。
每種狀態都有多個值,代表不同的含義。
比如訂單狀態有:
如果沒有使用列舉,一般是這樣做的:
public static final int ORDER_STATUS_CREATE = 1;
public static final int ORDER_STATUS_PAY = 2;
public static final int ORDER_STATUS_DONE = 3;
public static final int ORDER_STATUS_CANCEL = 4;
public static final String ORDER_STATUS_CREATE_MESSAGE = "下單";
public static final String ORDER_STATUS_PAY = "下單";
public static final String ORDER_STATUS_DONE = "下單";
public static final String ORDER_STATUS_CANCEL = "下單";
需要定義很多靜態常數,包含不同的狀態和狀態的描述。
使用列舉
定義之後,程式碼如下:
public enum OrderStatusEnum {
CREATE(1, "下單"),
PAY(2, "支付"),
DONE(3, "完成"),
CANCEL(4, "復原");
private int code;
private String message;
OrderStatusEnum(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return this.code;
}
public String getMessage() {
return this.message;
}
public static OrderStatusEnum getOrderStatusEnum(int code) {
return Arrays.stream(OrderStatusEnum.values()).filter(x -> x.code == code).findFirst().orElse(null);
}
}
使用列舉改造之後,職責更單一了。
而且使用列舉的好處是:
不知道你在實際的專案開發中,有沒有使用過固定值?
例如:
if(user.getId() < 1000L) {
doSamething();
}
或者:
if(Objects.isNull(user)) {
throw new BusinessException("該使用者不存在");
}
其中1000L
和該使用者不存在
是固定值,每次都是一樣的。
既然是固定值,我們為什麼不把它們定義成靜態常數呢?
這樣語意上更直觀,方便統一管理和維護,更方便程式碼複用。
程式碼優化為:
private static final int DEFAULT_USER_ID = 1000L;
...
if(user.getId() < DEFAULT_USER_ID) {
doSamething();
}
或者:
private static final String NOT_FOUND_MESSAGE = "該使用者不存在";
...
if(Objects.isNull(user)) {
throw new BusinessException(NOT_FOUND_MESSAGE);
}
使用static final
關鍵字修飾靜態常數,static
表示靜態
的意思,即類變數,而final
表示不允許修改
。
兩個關鍵字加在一起,告訴Java虛擬機器器這種變數,在記憶體中只有一份,在全域性上是唯一的,不能修改,也就是靜態常數
。
很多小夥伴在使用spring框架開發專案時,為了方便,喜歡使用@Transactional
註解提供事務功能。
沒錯,使用@Transactional註解這種宣告式事務的方式提供事務功能,確實能少寫很多程式碼,提升開發效率。
但也容易造成大事務,引發其他的問題。
下面用一張圖看看大事務引發的問題。
從圖中能夠看出,大事務問題可能會造成介面超時,對介面的效能有直接的影響。
我們該如何優化大事務呢?
關於大事務問題我的另一篇文章《讓人頭痛的大事務問題到底要如何解決?》,它裡面做了非常詳細的介紹,如果大家感興趣可以看看。
我們在寫程式碼的時候,if...else的判斷條件是必不可少的。不同的判斷條件,走的程式碼邏輯通常會不一樣。
廢話不多說,先看看下面的程式碼。
public interface IPay {
void pay();
}
@Service
public class AliaPay implements IPay {
@Override
public void pay() {
System.out.println("===發起支付寶支付===");
}
}
@Service
public class WeixinPay implements IPay {
@Override
public void pay() {
System.out.println("===發起微信支付===");
}
}
@Service
public class JingDongPay implements IPay {
@Override
public void pay() {
System.out.println("===發起京東支付===");
}
}
@Service
public class PayService {
@Autowired
private AliaPay aliaPay;
@Autowired
private WeixinPay weixinPay;
@Autowired
private JingDongPay jingDongPay;
public void toPay(String code) {
if ("alia".equals(code)) {
aliaPay.pay();
} elseif ("weixin".equals(code)) {
weixinPay.pay();
} elseif ("jingdong".equals(code)) {
jingDongPay.pay();
} else {
System.out.println("找不到支付方式");
}
}
}
PayService類的toPay方法主要是為了發起支付,根據不同的code,決定呼叫用不同的支付類(比如:aliaPay)的pay方法進行支付。
這段程式碼有什麼問題呢?也許有些人就是這麼幹的。
試想一下,如果支付方式越來越多,比如:又加了百度支付、美團支付、銀聯支付等等,就需要改toPay方法的程式碼,增加新的else...if判斷,判斷多了就會導致邏輯越來越多?
很明顯,這裡違法了設計模式六大原則的:開閉原則 和 單一職責原則。
開閉原則:對擴充套件開放,對修改關閉。就是說增加新功能要儘量少改動已有程式碼。
單一職責原則:顧名思義,要求邏輯儘量單一,不要太複雜,便於複用。
那麼,如何優化if...else判斷呢?
答:使用 策略模式
+工廠模式
。
策略模式定義了一組演演算法,把它們一個個封裝起來, 並且使它們可相互替換。
工廠模式用於封裝和管理物件的建立,是一種建立型模式。
public interface IPay {
void pay();
}
@Service
public class AliaPay implements IPay {
@PostConstruct
public void init() {
PayStrategyFactory.register("aliaPay", this);
}
@Override
public void pay() {
System.out.println("===發起支付寶支付===");
}
}
@Service
public class WeixinPay implements IPay {
@PostConstruct
public void init() {
PayStrategyFactory.register("weixinPay", this);
}
@Override
public void pay() {
System.out.println("===發起微信支付===");
}
}
@Service
public class JingDongPay implements IPay {
@PostConstruct
public void init() {
PayStrategyFactory.register("jingDongPay", this);
}
@Override
public void pay() {
System.out.println("===發起京東支付===");
}
}
public class PayStrategyFactory {
private static Map<String, IPay> PAY_REGISTERS = new HashMap<>();
public static void register(String code, IPay iPay) {
if (null != code && !"".equals(code)) {
PAY_REGISTERS.put(code, iPay);
}
}
public static IPay get(String code) {
return PAY_REGISTERS.get(code);
}
}
@Service
public class PayService3 {
public void toPay(String code) {
PayStrategyFactory.get(code).pay();
}
}
這段程式碼的關鍵是PayStrategyFactory類,它是一個策略工廠,裡面定義了一個全域性的map,在所有IPay的實現類中註冊當前範例到map中,然後在呼叫的地方通過PayStrategyFactory類根據code從map獲取支付類範例即可。
如果加了一個新的支付方式,只需新加一個類實現IPay介面,定義init方法,並且重寫pay方法即可,其他程式碼基本上可以不用動。
當然,消除又臭又長的if...else判斷,還有很多方法,比如:使用註解、動態拼接類名稱、模板方法、列舉等等。由於篇幅有限,在這裡我就不過多介紹了,更詳細的內容可以看看我的另一篇文章《消除if...else是9條錦囊妙計》
有些小夥伴看到這個標題,可能會感到有點意外,程式碼中不是應該避免死迴圈嗎?為啥還是會產生死迴圈?
殊不知有些死迴圈是我們自己寫的,例如下面這段程式碼:
while(true) {
if(condition) {
break;
}
System.out.println("do samething");
}
這裡使用了while(true)的迴圈呼叫,這種寫法在CAS自旋鎖
中使用比較多。
當滿足condition等於true的時候,則自動退出該回圈。
如果condition條件非常複雜,一旦出現判斷不正確,或者少寫了一些邏輯判斷,就可能在某些場景下出現死迴圈的問題。
出現死迴圈,大概率是開發人員人為的bug導致的,不過這種情況很容易被測出來。
還有一種隱藏的比較深的死迴圈,是由於程式碼寫的不太嚴謹導致的。如果用正常資料,可能測不出問題,但一旦出現異常資料,就會立即出現死迴圈。
其實,還有另一種死迴圈:無限遞迴
。
如果想要列印某個分類的所有父分類,可以用類似這樣的遞迴方法實現:
public void printCategory(Category category) {
if(category == null
|| category.getParentId() == null) {
return;
}
System.out.println("父分類名稱:"+ category.getName());
Category parent = categoryMapper.getCategoryById(category.getParentId());
printCategory(parent);
}
正常情況下,這段程式碼是沒有問題的。
但如果某次有人誤操作,把某個分類的parentId指向了它自己,這樣就會出現無限遞迴的情況。導致介面一直不能返回資料,最終會發生堆疊溢位。
建議寫遞迴方法時,設定一個遞迴的深度,比如:分類最大等級有4級,則深度可以設定為4。然後在遞迴方法中做判斷,如果深度大於4時,則自動返回,這樣就能避免無限迴圈的情況。
通常我們會把一些小數型別的欄位(比如:金額),定義成BigDecimal
,而不是Double
,避免丟失精度問題。
使用Double時可能會有這種場景:
double amount1 = 0.02;
double amount2 = 0.03;
System.out.println(amount2 - amount1);
正常情況下預計amount2 - amount1應該等於0.01
但是執行結果,卻為:
0.009999999999999998
實際結果小於預計結果。
Double型別的兩個引數相減會轉換成二進位制,因為Double有效位數為16位元這就會出現儲存小數位數不夠的情況,這種情況下就會出現誤差。
常識告訴我們使用BigDecimal
能避免丟失精度。
但是使用BigDecimal能避免丟失精度嗎?
答案是否定的。
為什麼?
BigDecimal amount1 = new BigDecimal(0.02);
BigDecimal amount2 = new BigDecimal(0.03);
System.out.println(amount2.subtract(amount1));
這個例子中定義了兩個BigDecimal型別引數,使用建構函式初始化資料,然後列印兩個引數相減後的值。
結果:
0.0099999999999999984734433411404097569175064563751220703125
不科學呀,為啥還是丟失精度了?
Jdk
中BigDecimal
的構造方法
上有這樣一段描述:
大致的意思是此建構函式的結果可能不可預測,可能會出現建立時為0.1,但實際是0.1000000000000000055511151231257827021181583404541015625的情況。
由此可見,使用BigDecimal建構函式初始化物件,也會丟失精度。
那麼,如何才能不丟失精度呢?
BigDecimal amount1 = new BigDecimal(Double.toString(0.02));
BigDecimal amount2 = new BigDecimal(Double.toString(0.03));
System.out.println(amount2.subtract(amount1));
我們可以使用Double.toString
方法,對double型別的小數進行轉換,這樣能保證精度不丟失。
其實,還有更好的辦法:
BigDecimal amount1 = BigDecimal.valueOf(0.02);
BigDecimal amount2 = BigDecimal.valueOf(0.03);
System.out.println(amount2.subtract(amount1));
使用BigDecimal.valueOf
方法初始化BigDecimal型別引數,也能保證精度不丟失。在新版的阿里巴巴開發手冊中,也推薦使用這種方式建立BigDecimal引數。
ctrl + c
和 ctrl + v
可能是程式設計師使用最多的快捷鍵了。
沒錯,我們是大自然的搬運工。哈哈哈。
在專案初期,我們使用這種工作模式,確實可以提高一些工作效率,可以少寫(實際上是少敲)很多程式碼。
但它帶來的問題是:會出現大量的程式碼重複。例如:
@Service
@Slf4j
public class TestService1 {
public void test1() {
addLog("test1");
}
private void addLog(String info) {
if (log.isInfoEnabled()) {
log.info("info:{}", info);
}
}
}
@Service
@Slf4j
public class TestService2 {
public void test2() {
addLog("test2");
}
private void addLog(String info) {
if (log.isInfoEnabled()) {
log.info("info:{}", info);
}
}
}
@Service
@Slf4j
public class TestService3 {
public void test3() {
addLog("test3");
}
private void addLog(String info) {
if (log.isInfoEnabled()) {
log.info("info:{}", info);
}
}
}
在TestService1、TestService2、TestService3類中,都有一個addLog方法用於新增紀錄檔。
本來該功能用得好好的,直到有一天,線上出現了一個事故:伺服器磁碟滿了。
原因是列印的紀錄檔太多,記了很多沒必要的紀錄檔,比如:查詢介面的所有返回值,大物件的具體列印等。
沒辦法,只能將addLog方法改成只記錄debug
紀錄檔。
於是乎,你需要全文搜尋,addLog方法去修改,改成如下程式碼:
private void addLog(String info) {
if (log.isDebugEnabled()) {
log.debug("debug:{}", info);
}
}
這裡是有三個類中需要修改這段程式碼,但如果實際工作中有三十個、三百個類需要修改,會讓你非常痛苦。改錯了,或者改漏了,都會埋下隱患,把自己坑了。
為何不把這種功能的程式碼提取出來,放到某個工具類中呢?
@Slf4j
public class LogUtil {
private LogUtil() {
throw new RuntimeException("初始化失敗");
}
public static void addLog(String info) {
if (log.isDebugEnabled()) {
log.debug("debug:{}", info);
}
}
}
然後,在其他的地方,只需要呼叫。
@Service
@Slf4j
public class TestService1 {
public void test1() {
LogUtil.addLog("test1");
}
}
如果哪天addLog的邏輯又要改了,只需要修改LogUtil類的addLog方法即可。你可以自信滿滿的修改,不需要再小心翼翼了。
我們寫的程式碼,絕大多數是可維護性的程式碼,而非一次性的。所以,建議在寫程式碼的過程中,如果出現重複的程式碼,儘量提取成公共方法。千萬別因為專案初期一時的爽快,而給專案埋下隱患,後面的維護成本可能會非常高。
我們知道在Java中,迴圈有很多種寫法,比如:while、for、foreach等。
public class Test2 {
public static void main(String[] args) {
List<String> list = Lists.newArrayList("a","b","c");
for (String temp : list) {
if ("c".equals(temp)) {
list.remove(temp);
}
}
System.out.println(list);
}
}
執行結果:
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
at java.util.ArrayList$Itr.next(ArrayList.java:851)
at com.sue.jump.service.test1.Test2.main(Test2.java:24)
這種在foreach
迴圈中呼叫remove
方法刪除元素,可能會報ConcurrentModificationException
異常。
如果想在遍歷集合時,刪除其中的元素,可以用for迴圈,例如:
public class Test2 {
public static void main(String[] args) {
List<String> list = Lists.newArrayList("a","b","c");
for (int i = 0; i < list.size(); i++) {
String temp = list.get(i);
if ("c".equals(temp)) {
list.remove(temp);
}
}
System.out.println(list);
}
}
執行結果:
[a, b]
在我們寫程式碼的時候,列印紀錄檔是必不可少的工作之一。
因為紀錄檔可以幫我們快速定位問題,判斷程式碼當時真正的執行邏輯。
但列印紀錄檔的時候也需要注意,不是說任何時候都要列印紀錄檔,比如:
@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
log.info("request params:{}", ids);
List<User> userList = userService.query(ids);
log.info("response:{}", userList);
return userList;
}
對於有些查詢介面,在紀錄檔中列印出了請求引數和介面返回值。
咋一看沒啥問題。
但如果ids中傳入值非常多,比如有1000個。而該介面被呼叫的頻次又很高,一下子就會列印大量的紀錄檔,用不了多久就可能把磁碟空間
打滿。
如果真的想列印這些紀錄檔該怎麼辦?
@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
if (log.isDebugEnabled()) {
log.debug("request params:{}", ids);
}
List<User> userList = userService.query(ids);
if (log.isDebugEnabled()) {
log.debug("response:{}", userList);
}
return userList;
}
使用isDebugEnabled
判斷一下,如果當前的紀錄檔級別是debug
才列印紀錄檔。生產環境預設紀錄檔級別是info
,在有些緊急情況下,把某個介面或者方法的紀錄檔級別改成debug,列印完我們需要的紀錄檔後,又調整回去。
方便我們定位問題,又不會產生大量的垃圾紀錄檔,一舉兩得。
在比較兩個引數值是否相等時,通常我們會使用==
號,或者equals
方法。
我在第15章節中說過,使用==
號比較兩個值是否相等時,可能會存在問題,建議使用equals
方法做比較。
反例
:
if(user.getName().equals("蘇三")) {
System.out.println("找到:"+user.getName());
}
在上面這段程式碼中,如果user物件,或者user.getName()方法返回值為null
,則都報NullPointerException
異常。
那麼,如何避免空指標異常呢?
正例
:
private static final String FOUND_NAME = "蘇三";
...
if(null == user) {
return;
}
if(FOUND_NAME.equals(user.getName())) {
System.out.println("找到:"+user.getName());
}
在使用equals
做比較時,儘量將常數
寫在前面,即equals方法的左邊。
這樣即使user.getName()返回的資料為null,equals方法會直接返回false,而不再是報空指標異常。
java中沒有強制規定引數、方法、類或者包名該怎麼起名。但如果我們沒有養成良好的起名習慣,隨意起名的話,可能會出現很多奇怪的程式碼。
有時候,我們寫程式碼時為了省事(可以少敲幾個字母),引數名起得越簡單越好。假如同事A寫的程式碼如下:
int a = 1;
int b = 2;
String c = "abc";
boolean b = false;
一段時間之後,同事A離職了,同事B接手了這段程式碼。
他此時一臉懵逼,a是什麼意思,b又是什麼意思,還有c...然後心裡一萬個草泥馬。
給引數起一個有意義的名字,是非常重要的事情,避免給自己或者別人埋坑。
正解:
int supplierCount = 1;
int purchaserCount = 2;
String userName = "abc";
boolean hasSuccess = false;
光起有意義的引數名還不夠,我們不能就這點追求。我們起的引數名稱最好能夠見名知意
,不然就會出現這樣的情況:
String yongHuMing = "蘇三";
String 使用者Name = "蘇三";
String su3 = "蘇三";
String suThree = "蘇三";
這幾種引數名看起來是不是有點怪怪的?
為啥不定義成國際上通用的(地球人都能看懂)英文單詞呢?
String userName = "蘇三";
String susan = "蘇三";
上面的這兩個引數名,基本上大家都能看懂,減少了好多溝通成本。
所以建議在定義不管是引數名、方法名、類名時,優先使用國際上通用的英文單詞,更簡單直觀,減少溝通成本。少用漢子、拼音,或者數位定義名稱。
引數名其實有多種風格,列如:
//字母全小寫
int suppliercount = 1;
//字母全大寫
int SUPPLIERCOUNT = 1;
//小寫字母 + 下劃線
int supplier_count = 1;
//大寫字母 + 下劃線
int SUPPLIER_COUNT = 1;
//駝峰標識
int supplierCount = 1;
如果某個類中定義了多種風格的引數名稱,看起來是不是有點雜亂無章?
所以建議類的成員變數、區域性變數和方法引數使用supplierCount,這種駝峰風格
,即:第一個字母小寫,後面的每個單詞首字母大寫。例如:
int supplierCount = 1;
此外,為了好做區分,靜態常數建議使用SUPPLIER_COUNT,即:大寫字母
+
下劃線
分隔的引數名。例如:
private static final int SUPPLIER_COUNT = 1;
在java8之前,我們對時間的格式化處理,一般都是用的SimpleDateFormat
類實現的。例如:
@Service
public class SimpleDateFormatService {
public Date time(String time) throws ParseException {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return dateFormat.parse(time);
}
}
如果你真的這樣寫,是沒問題的。
就怕哪天抽風,你覺得dateFormat是一段固定的程式碼,應該要把它抽取成常數。
於是把程式碼改成下面的這樣:
@Service
public class SimpleDateFormatService {
private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public Date time(String time) throws ParseException {
return dateFormat.parse(time);
}
}
dateFormat物件被定義成了靜態常數,這樣就能被所有物件共用。
如果只有一個執行緒呼叫time方法,也不會出現問題。
但Serivce類的方法,往往是被Controller類呼叫的,而Controller類的介面方法,則會被tomcat
的執行緒池
呼叫。換句話說,可能會出現多個執行緒呼叫同一個Controller類的同一個方法,也就是會出現多個執行緒會同時呼叫time方法。
而time方法會呼叫SimpleDateFormat
類的parse
方法:
@Override
public Date parse(String text, ParsePosition pos) {
...
Date parsedDate;
try {
parsedDate = calb.establish(calendar).getTime();
...
} catch (IllegalArgumentException e) {
pos.errorIndex = start;
pos.index = oldStart;
return null;
}
return parsedDate;
}
該方法會呼叫establish
方法:
Calendar establish(Calendar cal) {
...
//1.清空資料
cal.clear();
//2.設定時間
cal.set(...);
//3.返回
return cal;
}
其中的步驟1、2、3是非原子操作。
但如果cal物件是區域性變數還好,壞就壞在parse方法呼叫establish方法時,傳入的calendar是SimpleDateFormat
類的父類別DateFormat
的成員變數:
public abstract class DateFormat extends Forma {
....
protected Calendar calendar;
...
}
這樣就可能會出現多個執行緒,同時修改同一個物件即:dateFormat,它的同一個成員變數即:Calendar值的情況。
這樣可能會出現,某個執行緒設定好了時間,又被其他的執行緒修改了,從而出現時間錯誤的情況。
那麼,如何解決這個問題呢?
我們都知道JDK5
之後,提供了ThreadPoolExecutor
類,用它可以自定義執行緒池
。
執行緒池的好處有很多,下面主要說說這3個方面。
降低資源消耗
:避免了頻繁的建立執行緒和銷燬執行緒,可以直接複用已有執行緒。而我們都知道,建立執行緒是非常耗時的操作。提供速度
:任務過來之後,因為執行緒已存在,可以拿來直接使用。提高執行緒的可管理性
:執行緒是非常寶貴的資源,如果建立過多的執行緒,不僅會消耗系統資源,甚至會影響系統的穩定。使用執行緒池,可以非常方便的建立、管理和監控執行緒。當然JDK為了我們使用更便捷,專門提供了:Executors
類,給我們快速建立執行緒池
。
該類中包含了很多靜態方法
:
newCachedThreadPool
:建立一個可緩衝的執行緒,如果執行緒池大小超過處理需要,可靈活回收空閒執行緒,若無可回收,則新建執行緒。newFixedThreadPool
:建立一個固定大小的執行緒池,如果任務數量超過執行緒池大小,則將多餘的任務放到佇列中。newScheduledThreadPool
:建立一個固定大小,並且能執行定時週期任務的執行緒池。newSingleThreadExecutor
:建立只有一個執行緒的執行緒池,保證所有的任務安裝順序執行。在高並行的場景下,如果大家使用這些靜態方法建立執行緒池,會有一些問題。
那麼,我們一起看看有哪些問題?
newFixedThreadPool
: 允許請求的佇列長度是Integer.MAX_VALUE,可能會堆積大量的請求,從而導致OOM。newSingleThreadExecutor
:允許請求的佇列長度是Integer.MAX_VALUE,可能會堆積大量的請求,從而導致OOM。newCachedThreadPool
:允許建立的執行緒數是Integer.MAX_VALUE,可能會建立大量的執行緒,從而導致OOM。那我們該怎辦呢?
優先推薦使用ThreadPoolExecutor
類,我們自定義執行緒池。
具體程式碼如下:
ExecutorService threadPool = new ThreadPoolExecutor(
8, //corePoolSize執行緒池中核心執行緒數
10, //maximumPoolSize 執行緒池中最大執行緒數
60, //執行緒池中執行緒的最大空閒時間,超過這個時間空閒執行緒將被回收
TimeUnit.SECONDS,//時間單位
new ArrayBlockingQueue(500), //佇列
new ThreadPoolExecutor.CallerRunsPolicy()); //拒絕策略
順便說一下,如果是一些低並行場景,使用Executors
類建立執行緒池也未嘗不可,也不能完全一棍子打死。在這些低並行場景下,很難出現OOM
問題,所以我們需要根據實際業務場景選擇。
在我們日常工作中,經常需要把陣列
轉換成List
集合。
因為陣列的長度是固定的,不太好擴容,而List的長度是可變的,它的長度會根據元素的數量動態擴容。
在JDK的Arrays
類中提供了asList
方法,可以把陣列
轉換成List
。
正例
:
String [] array = new String [] {"a","b","c"};
List<String> list = Arrays.asList(array);
for (String str : list) {
System.out.println(str);
}
在這個例子中,使用Arrays.asList方法將array陣列,直接轉換成了list。然後在for迴圈中遍歷list,列印出它裡面的元素。
如果轉換後的list,只是使用,沒新增或修改元素,不會有問題。
反例
:
String[] array = new String[]{"a", "b", "c"};
List<String> list = Arrays.asList(array);
list.add("d");
for (String str : list) {
System.out.println(str);
}
執行結果:
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at com.sue.jump.service.test1.Test2.main(Test2.java:24)
會直接報UnsupportedOperationException
異常。
為什麼呢?
答:使用Arrays.asList
方法轉換後的ArrayList
,是Arrays
類的內部類,並非java.util
包下我們常用的ArrayList
。
Arrays類的內部ArrayList類,它沒有實現父類別的add和remove方法,用的是父類別AbstractList的預設實現。
我們看看AbstractList
是如何實現的:
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
public E remove(int index) {
throw new UnsupportedOperationException();
}
該類的add
和remove
方法直接拋異常了,因此呼叫Arrays類的內部ArrayList類的add和remove方法,同樣會拋異常。
說實話,Java程式碼優化是一個比較大的話題,它裡面可以優化的點非常多,我沒辦法一一列舉完。在這裡只能拋磚引玉,介紹一下比較常見的知識點,更全面的內容,需要小夥伴們自己去思考和探索。
這篇文章寫了很久,花了很多時間和心思,如果你看了文章有些收穫,記得給我點贊鼓勵一下喔。
如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維條碼關注一下,您的支援是我堅持寫作最大的動力。
求一鍵三連:點贊、轉發、在看。
關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。