OPPO 後端面試涼經(附詳細參考答案)

這篇文章的問題來源於一個讀者之前分享的 OPPO 後端涼經,我對比較典型的一些問題進行了分類並給出了詳細的參考答案。希望能對正在參加面試的朋友們能夠有點幫助!

Java

String 爲什麼是不可變的?

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
  //...
}

String 真正不可變有下面幾點原因:

  1. 保存字符串的數組被 final 修飾且爲私有的,並且String 類沒有提供/暴露修改這個字符串的方法。
  2. String 類被 final 修飾導致其不能被繼承,進而避免了子類破壞 String 不可變。

在 Java 9 之後,StringStringBuilderStringBuffer 的實現改用 byte 數組存儲字符串。

public final class String implements java.io.Serializable,Comparable<String>, CharSequence {
    // @Stable 註解表示變量最多被修改一次,稱爲“穩定的”。
    @Stable
    private final byte[] value;
}

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    byte[] value;

}

新版的 String 其實支持兩個編碼方案:Latin-1 和 UTF-16。如果字符串中包含的漢字沒有超過 Latin-1 可表示範圍內的字符,那就會使用 Latin-1 作爲編碼方案。Latin-1 編碼方案下,byte 佔一個字節(8 位),char 佔用 2 個字節(16),byte 相較 char 節省一半的內存空間。

JDK 官方就說了絕大部分字符串對象只包含 Latin-1 可表示的字符。

如果字符串中包含的漢字超過 Latin-1 可表示範圍內的字符,bytechar 所佔用的空間是一樣的。

這是官方的介紹:https://openjdk.java.net/jeps/254

如何創建線程?

一般來說,創建線程有很多種方式,例如繼承Thread類、實現Runnable接口、實現Callable接口、使用線程池、使用CompletableFuture類等等。

不過,這些方式其實並沒有真正創建出線程。準確點來說,這些都屬於是在 Java 代碼中使用多線程的方法。

嚴格來說,Java 就只有一種方式可以創建線程,那就是通過new Thread().start()創建。不管是哪種方式,最終還是依賴於new Thread().start()

關於這個問題的詳細分析可以查看這篇文章:大家都說 Java 有三種創建線程的方式!併發編程中的驚天騙局!

Java 線程的狀態有哪幾種?

Java 線程在運行的生命週期中的指定時刻只可能處於下面 6 種不同狀態的其中一個狀態:

  • NEW: 初始狀態,線程被創建出來但沒有被調用 start()
  • RUNNABLE: 運行狀態,線程被調用了 start()等待運行的狀態。
  • BLOCKED:阻塞狀態,需要等待鎖釋放。
  • WAITING:等待狀態,表示該線程需要等待其他線程做出一些特定動作(通知或中斷)。
  • TIME_WAITING:超時等待狀態,可以在指定的時間後自行返回而不是像 WAITING 那樣一直等待。
  • TERMINATED:終止狀態,表示該線程已經運行完畢。

線程在生命週期中並不是固定處於某一個狀態而是隨着代碼的執行在不同狀態之間切換。

Java 線程狀態變遷圖(圖源:挑錯 |《Java 併發編程的藝術》中關於線程狀態的三處錯誤):

Java 線程狀態變遷圖

由上圖可以看出:線程創建之後它將處於 NEW(新建) 狀態,調用 start() 方法後開始運行,線程這時候處於 READY(可運行) 狀態。可運行狀態的線程獲得了 CPU 時間片(timeslice)後就處於 RUNNING(運行) 狀態。

在操作系統層面,線程有 READY 和 RUNNING 狀態;而在 JVM 層面,只能看到 RUNNABLE 狀態(圖源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系統一般將這兩個狀態統稱爲 RUNNABLE(運行中) 狀態 。

爲什麼 JVM 沒有區分這兩種狀態呢? (摘自:Java 線程運行怎麼有第六種狀態? - Dawell 的回答 ) 現在的時分(time-sharing)多任務(multi-task)操作系統架構通常都是用所謂的“時間分片(time quantum or time slice)”方式進行搶佔式(preemptive)輪轉調度(round-robin 式)。這個時間分片通常是很小的,一個線程一次最多隻能在 CPU 上運行比如 10-20ms 的時間(此時處於 running 狀態),也即大概只有 0.01 秒這一量級,時間片用後就要被切換下來放入調度隊列的末尾等待再次調度。(也即回到 ready 狀態)。線程切換的如此之快,區分這兩種狀態就沒什麼意義了。

RUNNABLE-VS-RUNNING

  • 當線程執行 wait()方法之後,線程進入 WAITING(等待) 狀態。進入等待狀態的線程需要依靠其他線程的通知才能夠返回到運行狀態。
  • TIMED_WAITING(超時等待) 狀態相當於在等待狀態的基礎上增加了超時限制,比如通過 sleep(long millis)方法或 wait(long millis)方法可以將線程置於 TIMED_WAITING 狀態。當超時時間結束後,線程將會返回到 RUNNABLE 狀態。
  • 當線程進入 synchronized 方法/塊或者調用 wait 後(被 notify)重新進入 synchronized 方法/塊,但是鎖被其它線程佔有,這個時候線程就會進入 BLOCKED(阻塞) 狀態。
  • 線程在執行完了 run()方法之後將會進入到 TERMINATED(終止) 狀態。

相關閱讀:線程的幾種狀態你真的瞭解麼?

爲什麼要用線程池?項目中使用的線程池是使用內置的還是自己創建的?

線程池提供了一種限制和管理資源(包括執行一個任務)的方式。 每個線程池還維護一些基本統計信息,例如已完成任務的數量。

這裏借用《Java 併發編程的藝術》提到的來說一下使用線程池的好處

  • 降低資源消耗。通過重複利用已創建的線程降低線程創建和銷燬造成的消耗。
  • 提高響應速度。當任務到達時,任務可以不需要等到線程創建就能立即執行。
  • 提高線程的可管理性。線程是稀缺資源,如果無限制的創建,不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一的分配,調優和監控。

《阿里巴巴 Java 開發手冊》中強制線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 構造函數的方式,這樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險

Executors 返回線程池對象的弊端如下(後文會詳細介紹到):

  • FixedThreadPoolSingleThreadExecutor :使用的是無界的 LinkedBlockingQueue,任務隊列最大長度爲 Integer.MAX_VALUE,可能堆積大量的請求,從而導致 OOM。
  • CachedThreadPool :使用的是同步隊列 SynchronousQueue, 允許創建的線程數量爲 Integer.MAX_VALUE ,如果任務數量過多且執行速度較慢,可能會創建大量的線程,從而導致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的無界的延遲阻塞隊列DelayedWorkQueue,任務隊列最大長度爲 Integer.MAX_VALUE,可能堆積大量的請求,從而導致 OOM。

相關閱讀:

數據庫

如何找到慢 SQL?

MySQL 慢查詢日誌是用來記錄 MySQL 在執行命令中,響應時間超過預設閾值的 SQL 語句。因此,通過分析慢查詢日誌我們就可以找出執行速度比較慢的 SQL 語句。

出於性能層面的考慮,慢查詢日誌功能默認是關閉的,你可以通過以下命令開啓:

# 開啓慢查詢日誌功能
SET GLOBAL slow_query_log = 'ON';
# 慢查詢日誌存放位置
SET GLOBAL slow_query_log_file = '/var/lib/mysql/ranking-list-slow.log';
# 無論是否超時,未被索引的記錄也會記錄下來。
SET GLOBAL log_queries_not_using_indexes = 'ON';
# 慢查詢閾值(秒),SQL 執行超過這個閾值將被記錄在日誌中。
SET SESSION long_query_time = 1;
# 慢查詢僅記錄掃描行數大於此參數的 SQL
SET SESSION min_examined_row_limit = 100;

設置成功之後,使用 show variables like 'slow%'; 命令進行查看。

| Variable_name       | Value                                |
+---------------------+--------------------------------------+
| slow_launch_time    | 2                                    |
| slow_query_log      | ON                                   |
| slow_query_log_file | /var/lib/mysql/ranking-list-slow.log |
+---------------------+--------------------------------------+
3 rows in set (0.01 sec)

我們故意在百萬數據量的表(未使用索引)中執行一條排序的語句:

SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;

確保自己有對應目錄的訪問權限:

chmod 755 /var/lib/mysql/

查看對應的慢查詢日誌:

 cat /var/lib/mysql/ranking-list-slow.log

我們剛剛故意執行的 SQL 語句已經被慢查詢日誌記錄了下來:

# Time: 2022-10-09T08:55:37.486797Z
# User@Host: root[root] @  [172.17.0.1]  Id:    14
# Query_time: 0.978054  Lock_time: 0.000164 Rows_sent: 999999  Rows_examined: 1999998
SET timestamp=1665305736;
SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;

這裏對日誌中的一些信息進行說明:

  • Time :被日誌記錄的代碼在服務器上的運行時間。
  • User@Host:誰執行的這段代碼。
  • Query_time:這段代碼運行時長。
  • Lock_time:執行這段代碼時,鎖定了多久。
  • Rows_sent:慢查詢返回的記錄。
  • Rows_examined:慢查詢掃描過的行數。

實際項目中,慢查詢日誌通常會比較複雜,我們需要藉助一些工具對其進行分析。像 MySQL 內置的 mysqldumpslow 工具就可以把相同的 SQL 歸爲一類,並統計出歸類項的執行次數和每次執行的耗時等一系列對應的情況。

如何分析 SQL 性能

我們可以使用 EXPLAIN 命令來分析 SQL 的 執行計劃 。執行計劃是指一條 SQL 語句在經過 MySQL 查詢優化器的優化會後,具體的執行方式。

EXPLAIN 並不會真的去執行相關的語句,而是通過 查詢優化器 對語句進行分析,找出最優的查詢方案,並顯示對應的信息。

EXPLAIN 適用於 SELECT, DELETE, INSERT, REPLACE, 和 UPDATE語句,我們一般分析 SELECT 查詢較多。

我們這裏簡單來演示一下 EXPLAIN 的使用。

EXPLAIN 的輸出格式如下:

mysql> EXPLAIN SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra          |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
|  1 | SIMPLE      | cus_order | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 997572 |   100.00 | Using filesort |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+----------------+
1 row in set, 1 warning (0.00 sec)

各個字段的含義如下:

列名 含義
id SELECT 查詢的序列標識符
select_type SELECT 關鍵字對應的查詢類型
table 用到的表名
partitions 匹配的分區,對於未分區的表,值爲 NULL
type 表的訪問方法
possible_keys 可能用到的索引
key 實際用到的索引
key_len 所選索引的長度
ref 當使用索引等值查詢時,與索引作比較的列或常量
rows 預計要讀取的行數
filtered 按表條件過濾後,留存的記錄數的百分比
Extra 附加信息

篇幅問題,我這裏只是簡單介紹了一下 MySQL 執行計劃,詳細介紹請看:MySQL 執行計劃分析這篇文章。

項目中是怎麼使用索引的?聯合索引瞭解嗎?

索引是一種用於快速查詢和檢索數據的數據結構,其本質可以看成是一種排序好的數據結構。

索引的作用就相當於書的目錄。打個比方: 我們在查字典的時候,如果沒有目錄,那我們就只能一頁一頁的去找我們需要查的那個字,速度很慢。如果有目錄了,我們只需要先去目錄裏查找字的位置,然後直接翻到那一頁就行了。

雖然索引能帶來查詢上的效率,但是維護索引的成本也是不小的。 如果一個字段不被經常查詢,反而被經常修改,那麼就更不應該在這種字段上建立索引了。

要選擇選擇合適的字段創建索引:

  • 不爲 NULL 的字段:索引字段的數據應該儘量不爲 NULL,因爲對於數據爲 NULL 的字段,數據庫較難優化。如果字段頻繁被查詢,但又避免不了爲 NULL,建議使用 0,1,true,false 這樣語義較爲清晰的短值或短字符作爲替代。
  • 被頻繁查詢的字段:我們創建索引的字段應該是查詢操作非常頻繁的字段。
  • 被作爲條件查詢的字段:被作爲 WHERE 條件查詢的字段,應該被考慮建立索引。
  • 頻繁需要排序的字段:索引已經排序,這樣查詢可以利用索引的排序,加快排序查詢時間。
  • 被經常頻繁用於連接的字段:經常用於連接的字段可能是一些外鍵列,對於外鍵列並不一定要建立外鍵,只是說該列涉及到表與表的關係。對於頻繁被連接查詢的字段,可以考慮建立索引,提高多表連接查詢的效率。

使用表中的多個字段創建索引,就是 聯合索引,也叫 組合索引複合索引

scorename 兩個字段建立聯合索引:

ALTER TABLE `cus_order` ADD INDEX id_score_name(score, name);

我們應該儘可能的考慮建立聯合索引而不是單列索引。因爲索引是需要佔用磁盤空間的,可以簡單理解爲每個索引都對應着一顆 B+樹。如果一個表的字段過多,索引過多,那麼當這個表的數據達到一個體量後,索引佔用的空間也是很多的,且修改索引時,耗費的時間也是較多的。如果是聯合索引,多個字段在一個索引上,那麼將會節約很大磁盤空間,且修改數據的操作效率也會提升。

緩存

Redis 提供的數據類型有哪些?

Redis 中比較常見的數據類型有下面這些:

  • 5 種基礎數據類型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
  • 3 種特殊數據類型:HyperLogLog(基數統計)、Bitmap (位圖)、Geospatial (地理位置)。

除了上面提到的之外,還有一些其他的比如 Bloom filter(布隆過濾器)、Bitfield(位域)。

String 的應用場景有哪些?底層實現是什麼?

String 是 Redis 中最簡單同時也是最常用的一個數據類型。它是一種二進制安全的數據類型,可以用來存儲任何類型的數據比如字符串、整數、浮點數、圖片(圖片的 base64 編碼或者解碼或者圖片的路徑)、序列化後的對象。

String 的常見應用場景如下:

  • 常規數據(比如 Session、Token、序列化後的對象、圖片的路徑)的緩存;
  • 計數比如用戶單位時間的請求數(簡單限流可以用到)、頁面單位時間的訪問數;
  • 分佈式鎖(利用 SETNX key value 命令可以實現一個最簡易的分佈式鎖);
  • ……

Redis 是基於 C 語言編寫的,但 Redis 的 String 類型的底層實現並不是 C 語言中的字符串(即以空字符 \0 結尾的字符數組),而是自己編寫了 SDS(Simple Dynamic String,簡單動態字符串) 來作爲底層實現。

SDS 最早是 Redis 作者爲日常 C 語言開發而設計的 C 字符串,後來被應用到了 Redis 上,並經過了大量的修改完善以適合高性能操作。

Redis7.0 的 SDS 的部分源碼如下(https://github.com/redis/redis/blob/7.0/src/sds.h):

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

通過源碼可以看出,SDS 共有五種實現方式 SDS_TYPE_5(並未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有後四種實際用到。Redis 會根據初始化的長度決定使用哪種類型,從而減少內存的使用。

類型 字節
sdshdr5 < 1 <8
sdshdr8 1 8
sdshdr16 2 16
sdshdr32 4 32
sdshdr64 8 64

對於後四種實現都包含了下面這 4 個屬性:

  • len:字符串的長度也就是已經使用的字節數
  • alloc:總共可用的字符空間大小,alloc-len 就是 SDS 剩餘的空間大小
  • buf[]:實際存儲字符串的數組
  • flags:低三位保存類型標誌

SDS 相比於 C 語言中的字符串有如下提升:

  1. 可以避免緩衝區溢出:C 語言中的字符串被修改(比如拼接)時,一旦沒有分配足夠長度的內存空間,就會造成緩衝區溢出。SDS 被修改時,會先根據 len 屬性檢查空間大小是否滿足要求,如果不滿足,則先擴展至所需大小再進行修改操作。
  2. 獲取字符串長度的複雜度較低:C 語言中的字符串的長度通常是經過遍歷計數來實現的,時間複雜度爲 O(n)。SDS 的長度獲取直接讀取 len 屬性即可,時間複雜度爲 O(1)。
  3. 減少內存分配次數:爲了避免修改(增加/減少)字符串時,每次都需要重新分配內存(C 語言的字符串是這樣的),SDS 實現了空間預分配和惰性空間釋放兩種優化策略。當 SDS 需要增加字符串時,Redis 會爲 SDS 分配好內存,並且根據特定的算法分配多餘的內存,這樣可以減少連續執行字符串增長操作所需的內存重分配次數。當 SDS 需要減少字符串時,這部分內存不會立即被回收,會被記錄下來,等待後續使用(支持手動釋放,有對應的 API)。
  4. 二進制安全:C 語言中的字符串以空字符 \0 作爲字符串結束的標識,這存在一些問題,像一些二進制文件(比如圖片、視頻、音頻)就可能包括空字符,C 字符串無法正確保存。SDS 使用 len 屬性判斷字符串是否結束,不存在這個問題。

🤐 多提一嘴,很多文章裏 SDS 的定義是下面這樣的:

struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

這個也沒錯,Redis 3.2 之前就是這樣定義的。後來,由於這種方式的定義存在問題,lenfree 的定義用了 4 個字節,造成了浪費。Redis 3.2 之後,Redis 改進了 SDS 的定義,將其劃分爲了現在的 5 種類型。

String 還是 Hash 存儲對象數據更好呢?

  • String 存儲的是序列化後的對象數據,存放的是整個對象。Hash 是對對象的每個字段單獨存儲,可以獲取部分字段的信息,也可以修改或者添加部分字段,節省網絡流量。如果對象中某些字段需要經常變動或者經常需要單獨查詢對象中的個別字段信息,Hash 就非常適合。
  • String 存儲相對來說更加節省內存,緩存相同數量的對象數據,String 消耗的內存約是 Hash 的一半。並且,存儲具有多層嵌套的對象時也方便很多。如果系統對性能和資源消耗非常敏感的話,String 就非常適合。

在絕大部分情況,我們建議使用 String 來存儲對象數據即可!

多級緩存的是怎麼做的?爲什麼還要再多加一層本地緩存呢?

這個問題的答案摘自《Java 面試指北》(高質量原創 Java 面試小冊)

我們這裏只來簡單聊聊 本地緩存 + 分佈式緩存 的多級緩存方案,這也是最常用的多級緩存實現方式。

這個時候估計有很多小夥伴就會問了:既然用了分佈式緩存,爲什麼還要用本地緩存呢?

本地緩存和分佈式緩存雖然都屬於緩存,但本地緩存的訪問速度要遠大於分佈式緩存,這是因爲訪問本地緩存不存在額外的網絡開銷,我們在上面也提到了。

不過,一般情況下,我們也是不建議使用多級緩存的,這會增加維護負擔(比如你需要保證一級緩存和二級緩存的數據一致性)。而且,其實際帶來的提升效果對於絕大部分業務場景來說其實並不是很大。

這裏簡單總結一下適合多級緩存的兩種業務場景:

  • 緩存的數據不會頻繁修改,比較穩定;
  • 數據訪問量特別大比如秒殺場景。

多級緩存方案中,第一級緩存(L1)使用本地內存(比如 Caffeine)),第二級緩存(L2)使用分佈式緩存(比如 Redis)。

多級緩存

如果 L2 也沒有此數據的話,再去數據庫查詢,數據查詢成功後再將數據寫入到 L1 和 L2 中。

J2Cache 就是一個基於本地內存和分佈式緩存的兩級 Java 緩存框架,感興趣的同學可以研究一下。

Redis 緩存穿透、緩存擊穿、緩存雪崩區別和解決方案

內容較多,單獨寫了一篇文章詳細介紹:Redis 緩存穿透、緩存擊穿、緩存雪崩區別和解決方案

如何保證緩存和數據庫數據的一致性?

細說的話可以扯很多,但是我覺得其實沒太大必要(小聲 BB:很多解決方案我也沒太弄明白)。我個人覺得引入緩存之後,如果爲了短時間的不一致性問題,選擇讓系統設計變得更加複雜的話,完全沒必要。

下面單獨對 Cache Aside Pattern(旁路緩存模式) 來聊聊。

Cache Aside Pattern 中遇到寫請求是這樣的:更新 DB,然後直接刪除 cache 。

如果更新數據庫成功,而刪除緩存這一步失敗的情況的話,簡單說兩個解決方案:

  1. 緩存失效時間變短(不推薦,治標不治本):我們讓緩存數據的過期時間變短,這樣的話緩存就會從數據庫中加載數據。另外,這種解決辦法對於先操作緩存後操作數據庫的場景不適用。
  2. 增加 cache 更新重試機制(常用):如果 cache 服務當前不可用導致緩存刪除失敗的話,我們就隔一段時間進行重試,重試次數可以自己定。如果多次重試還是失敗的話,我們可以把當前更新失敗的 key 存入隊列中,等緩存服務可用之後,再將緩存中對應的 key 刪除即可。

詳細介紹可以參考這篇文章:緩存和數據庫一致性問題,看這篇就夠了 - 水滴與銀彈

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章