《关于我横扫一线厂的那些面经》拼多多Java岗(附答案)
前言
去年年底面試了多多買菜,有圖為證,現(xiàn)整理面經(jīng),希望各位不要覺得太遲(這該死的拖延癥😂)。
周日晚上8點視頻面試的拼多多,結(jié)果人家全員加班中,辦公室中都是人,所以大家去多多前還是思考下把。🤣
?
問題1.arraylist線程是否安全,具體體現(xiàn)在哪行?
答:線程不安全,具體表現(xiàn)新增元素的賦值操作elementData[size++]=e。
?
我們先來看下什么是線程的安全性:
線程安全就是多線程訪問時對數(shù)據(jù)進行了加鎖機制(樂觀鎖或悲觀鎖),只有一個線程能夠正常訪問,其他線程不能進行訪問直到該線程讀取完。不會出現(xiàn)數(shù)據(jù)不一致或者數(shù)據(jù)污染。
線程不安全就是多線程訪問時不提供數(shù)據(jù)訪問保護,有可能出現(xiàn)多個線程先后更改數(shù)據(jù),所以得到的數(shù)據(jù)是臟數(shù)據(jù)。
?
如圖,AbstractList下面有兩個類,一個是線程不安全ArrayList,另外一個是線程安全vector。
?
從源碼的角度來看,因為Vector的方法前加了synchronized 關(guān)鍵字,也就是同步的意思,所以其是線程安全的。
?
所以我們看到這兩個的區(qū)別:
Vector:線程安全,但是性能低。
ArrayList:線程不安全,但是性能高,高效。
?
所以自古魚和熊掌不可兼得。
?
我們來看下具體的代碼,從下圖我們可以得出其添加元素時候的兩步走:
1. 在 Items[Size] 的位置存放此元素;
2. 增大 Size 的值。
?
我們來思考下:
在單線程運行的情況下,如果 Size = 0,添加一個元素后,此元素在位置 0,而且 Size=1,一切都是正常的;
而如果是在多線程情況下,比如有兩個線程,線程 A 先將元素存放在位置 0,此時size并沒有加一,但是此時 CPU 調(diào)度線程A暫停,線程 B 得到運行的機會。
線程B也向此 ArrayList 添加元素,因為此時 Size 仍然等于 0 (注意哦,我們假設(shè)的是添加一個元素是要兩個步驟哦,而線程A僅僅完成了步驟1),
所以線程B也將元素存放在位置0,此時size也沒有加1。然后線程A和線程B都繼續(xù)運行,都增加 Size 的值,此時size等于2。
那好,現(xiàn)在我們發(fā)現(xiàn)問題了,元素實際上只有一個,存放在位置 0,而 Size 卻等于 2。這就是“線程不安全”了。
?
看下面的代碼,在多線程并發(fā)情況下,提示了報錯信息,程序報了并發(fā)修改異常ConcurrentModificationException,我們也可以通過看下ArrayList底層中add()方法,是沒有加鎖的操作,當(dāng)多個線程共享一份資源時,可能發(fā)生線程問題;arrayList的add()方法是沒有加鎖的。
public static void main(String[] args) throws InterruptedException {List<Integer> list = new ArrayList<>();ExecutorService threadPool = Executors.newFixedThreadPool(30);for (int i = 1; i <= 30; i++) {int finalI = i;threadPool.execute(() -> {list.add(finalI);System.out.println(list);});}threadPool.shutdown();}?
如果我們想要不報錯,可以將list轉(zhuǎn)化為線程安全的集合,使用Collections工具類的synchronizedList方法,來將其轉(zhuǎn)化線程安全的。
public static void main(String[] args) throws InterruptedException {List<Integer> list = Collections.synchronizedList(new ArrayList<>());//此處只是案例demo,真實使用線程池不建議用這種方式創(chuàng)建ExecutorService threadPool = Executors.newFixedThreadPool(30);for (int i = 1; i <= 30; i++) {int finalI = i;threadPool.execute(() -> {list.add(finalI);System.out.println(list);});}threadPool.shutdown();}?
問題2.hashmap為什么用紅黑樹,不要AVL樹,或B+樹?
答:因為AVL樹比紅黑樹保持更加嚴格的平衡,是以更多旋轉(zhuǎn)操作導(dǎo)致更慢的插入和刪除為代價的樹,B+樹所有的節(jié)點擠在一起,當(dāng)數(shù)據(jù)量不多的時候會退化成鏈表。
?
AVL樹
平衡二叉搜索樹(Self-balancing binary search tree)又被稱為AVL樹,且具有以下性質(zhì):它是一 棵空樹或它的左右兩個子樹的高度差的絕對值不超過1,并且左右兩個子樹都是一棵平衡二叉樹。
某在AVL樹中查找通常更快,但這是以更多旋轉(zhuǎn)操作導(dǎo)致更慢的插入和刪除為代價的。因此,如果希望查找次數(shù)主導(dǎo)樹的更新次數(shù),請使用AVL樹。
下面兩張圖都是平衡二叉搜索樹。
? ? ? ? ? ? ? ? ? ? ? ? ? ?
?
那我們來看下不是平衡二叉搜索樹長什么樣子?從下圖可以看到C節(jié)點的左子樹有F,L,M,即為兩層,但是C節(jié)點的右子樹并沒有,他們相差了2,所以并不是平衡二叉搜索樹。
左子樹的左子樹插入結(jié)點 (左左)
?
右子樹的右子樹插入節(jié)點 (右右)
?
左子樹的右子樹插入節(jié)點 (左右)
?
右子樹的左子樹插入節(jié)點 (右左)
B+樹
B+樹的非葉子結(jié)點不存儲數(shù)據(jù),所以每個結(jié)點能存儲的關(guān)鍵字更多。所以B+樹更能應(yīng)對大量數(shù)據(jù)的情況。jdk1.7中的HashMap本來是數(shù)組+鏈表的形式,鏈表由于其查找慢的特點,所以需要被查找效率更高的樹結(jié)構(gòu)來替換。
如果用B+樹的話,在數(shù)據(jù)量不是很多的情況下,數(shù)據(jù)都會“擠在”一個結(jié)點里面。這個時候遍歷效率就退化成了鏈表
?
一個m階的B樹具有如下幾個特征:
1.根結(jié)點至少有兩個子女。
2.每個中間節(jié)點都至少包含ceil(m / 2)個孩子,最多有m個孩子。
3.每一個葉子節(jié)點都包含k-1個元素,其中 m/2 <= k <= m。
4.所有的葉子結(jié)點都位于同一層。
5.每個節(jié)點中的元素從小到大排列,節(jié)點當(dāng)中k-1個元素正好是k個孩子包含的元素的值域分劃。
?
?
問題3.ConcurrentHashMap為什么線程安全?哪些點保證了?
答:數(shù)組初始化的時候自旋來保證一定可以初始化成功,然后通過 CAS 設(shè)置 SIZECTL 變量的值,來保證同一時刻只能有一個線程對數(shù)組進行初始化,CAS 成功之后,還會再次判斷當(dāng)前數(shù)組是否已經(jīng)初始化完成,如果已經(jīng)初始化完成,就不會再次初始化;
新增槽點時通過自旋保證一定新增成功,然后通過CAS來新增,如果遇到槽點有值,通過鎖住當(dāng)前槽點或紅黑樹的根節(jié)點;
擴容時通過鎖住原數(shù)組的槽點,設(shè)置轉(zhuǎn)移節(jié)點,以及自旋等操作來保證線程安全。
?
ConcurrentHashMap 在 put 方法上的整體思路:
1. 如果數(shù)組為空,初始化,初始化完成之后,走 2;
2. 計算當(dāng)前槽點有沒有值,沒有值的話,cas 創(chuàng)建,失敗繼續(xù)自旋(for 死循環(huán)),直到成功,槽點有值的話,走 3;
3. 如果槽點是轉(zhuǎn)移節(jié)點(正在擴容),就會一直自旋等待擴容完成之后再新增,不是轉(zhuǎn)移節(jié)點走4;
4. 槽點有值的,先鎖定當(dāng)前槽點,保證其余線程不能操作,如果是鏈表,新增值到鏈表的尾部,如果是紅黑樹,使用紅黑樹新增的方法新增;
5. 新增完成之后 check 需不需要擴容,需要的話去擴容。
具體源碼如下:
數(shù)組初始化時的線程安全
數(shù)組初始化時,首先通過自旋來保證一定可以初始化成功,然后通過 CAS 設(shè)置 SIZECTL 變量的值,來保證同一時刻只能有一個線程對數(shù)組進行初始化,CAS 成功之后,還會再次判斷當(dāng)前數(shù)組是否已經(jīng)初始化完成,如果已經(jīng)初始化完成,就不會再次初始化,通過自旋 + CAS + 雙重 check等手段保證了數(shù)組初始化時的線程安全,源碼如下:
? ? ? ? ? ? ? ? ? ?
?新增槽點值時的線程安全
?此時為了保證線程安全,做了四處優(yōu)化:
- ?通過自旋死循環(huán)保證一定可以新增成功。
- ?當(dāng)前槽點為空時,通過 CAS 新增。
- ?當(dāng)前槽點有值,鎖住當(dāng)前槽點。
- ?紅黑樹旋轉(zhuǎn)時,鎖住紅黑樹的根節(jié)點,保證同一時刻,當(dāng)前紅黑樹只能被一個線程旋轉(zhuǎn)
擴容中時的線程安全
- 拷貝槽點時,會把原數(shù)組的槽點鎖住;
- 拷貝成功之后,會把原數(shù)組的槽點設(shè)置成轉(zhuǎn)移節(jié)點,這樣如果有數(shù)據(jù)需要 put 到該節(jié)點時,發(fā)現(xiàn)該槽點是轉(zhuǎn)移節(jié)點,會一直等待,直到擴容成功之后,才能繼續(xù) put,可以參考 put 方 法中的 helpTransfer 方法;
- 從尾到頭進行拷貝,拷貝成功就把原數(shù)組的槽點設(shè)置成轉(zhuǎn)移節(jié)點。
- 等擴容拷貝都完成之后,直接把新數(shù)組的值賦值給數(shù)組容器,之前等待 put 的數(shù)據(jù)才能繼續(xù)?put。
? ? ? ? ? ? ? ? ? ??
// 擴容主要分 2 步,第一新建新的空數(shù)組,第二移動拷貝每個元素到新數(shù)組中去// tab:原數(shù)組,nextTab:新數(shù)組private final void transfer (Node < K, V >[]tab, Node < K, V >[]nextTab){// 老數(shù)組的長度int n = tab.length, stride;if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)stride = MIN_TRANSFER_STRIDE; // subdivide range// 如果新數(shù)組為空,初始化,大小為原數(shù)組的兩倍,n << 1if (nextTab == null) { // initiatingtry {@SuppressWarnings("unchecked")Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1];nextTab = nt;} catch (Throwable ex) { // try to cope with OOMEsizeCtl = Integer.MAX_VALUE;return;}nextTable = nextTab;transferIndex = n;}// 新數(shù)組的長度int nextn = nextTab.length;// 代表轉(zhuǎn)移節(jié)點,如果原數(shù)組上是轉(zhuǎn)移節(jié)點,說明該節(jié)點正在被擴容ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab);boolean advance = true;boolean finishing = false; // to ensure sweep before committing nextTab// 無限自旋,i 的值會從原數(shù)組的最大值開始,慢慢遞減到 0for (int i = 0, bound = 0; ; ) {Node<K, V> f;int fh;while (advance) {int nextIndex, nextBound;// 結(jié)束循環(huán)的標志if (--i >= bound || finishing)advance = false;// 已經(jīng)拷貝完成else if ((nextIndex = transferIndex) <= 0) {i = -1;advance = false;}// 每次減少 i 的值else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,nextBound = (nextIndex > stride ?nextIndex - stride : 0))) {bound = nextBound;i = nextIndex - 1;advance = false;}}// if 任意條件滿足說明拷貝結(jié)束了if (i < 0 || i >= n || i + n >= nextn) {int sc;// 拷貝結(jié)束,直接賦值,因為每次拷貝完一個節(jié)點,都在原數(shù)組上放轉(zhuǎn)移節(jié)點,所以拷貝完成的節(jié)點的數(shù)據(jù)一定不會再發(fā)生變化。// 原數(shù)組發(fā)現(xiàn)是轉(zhuǎn)移節(jié)點,是不會操作的,會一直等待轉(zhuǎn)移節(jié)點消失之后在進行操作。// 也就是說數(shù)組節(jié)點一旦被標記為轉(zhuǎn)移節(jié)點,是不會再發(fā)生任何變動的,所以不會有任何線程安全的問題// 所以此處直接賦值,沒有任何問題。if (finishing) {nextTable = null;table = nextTab;sizeCtl = (n << 1) - (n >>> 1);return;}if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)return;finishing = advance = true;i = n; // recheck before commit}} else if ((f = tabAt(tab, i)) == null)advance = casTabAt(tab, i, null, fwd);else if ((fh = f.hash) == MOVED)advance = true; // already processedelse {synchronized (f) {// 進行節(jié)點的拷貝if (tabAt(tab, i) == f) {Node<K, V> ln, hn;if (fh >= 0) {int runBit = fh & n;Node<K, V> lastRun = f;for (Node<K, V> p = f.next; p != null; p = p.next) {int b = p.hash & n;if (b != runBit) {runBit = b;lastRun = p;}}if (runBit == 0) {ln = lastRun;hn = null;} else {hn = lastRun;ln = null;}// 如果節(jié)點只有單個數(shù)據(jù),直接拷貝,如果是鏈表,循環(huán)多次組成鏈表拷貝for (Node<K, V> p = f; p != lastRun; p = p.next) {int ph = p.hash;K pk = p.key;V pv = p.val;if ((ph & n) == 0)ln = new Node<K, V>(ph, pk, pv, ln);elsehn = new Node<K, V>(ph, pk, pv, hn);}// 在新數(shù)組位置上放置拷貝的值setTabAt(nextTab, i, ln);setTabAt(nextTab, i + n, hn);// 在老數(shù)組位置上放上 ForwardingNode 節(jié)點// put 時,發(fā)現(xiàn)是 ForwardingNode 節(jié)點,就不會再動這個節(jié)點的數(shù)據(jù)了setTabAt(tab, i, fwd);advance = true;}// 紅黑樹的拷貝else if (f instanceof TreeBin) {// 紅黑樹的拷貝工作,同 HashMap 的內(nèi)容,代碼忽略// 在老數(shù)組位置上放上 ForwardingNode 節(jié)點setTabAt(tab, i, fwd);advance = true;}}}}}}?
問題4.剛才說到分布式鎖,談?wù)勗O(shè)計思路和方案
答:主要根據(jù)具體的業(yè)務(wù)場景展開描述(這邊各個項目不一樣,就不展開說了),主要是引入redis實現(xiàn)的分布式鎖,應(yīng)該保證互斥性(在任何時候只有一個客戶端持有鎖,使用setnx),不能死鎖(設(shè)置過期時間),保證上鎖和解鎖是同一個客戶端(設(shè)置不同的value值),業(yè)務(wù)時間太長,導(dǎo)致鎖過期(設(shè)置看門狗,自動續(xù)鎖),鎖的重入性(使用redis的hset)。
?
如果在一個分布式系統(tǒng)中,我們從數(shù)據(jù)庫中讀取一個數(shù)據(jù),然后修改保存,這種情況很容易遇到并發(fā)問題。因為讀取和更新保存不是一個原子操作,在并發(fā)時就會導(dǎo)致數(shù)據(jù)的不正確。如果是單機應(yīng)用,直接使用本地鎖就可以避免。如果是分布式應(yīng)用,應(yīng)用部署在多個JVM中,沒有辦法控制,所以引入分布式鎖來解決。
?
互斥性
127.0.0.1:6379> setnx lock value1 #在鍵lock不存在的情況下,將鍵key的值設(shè)置為value1 (integer) 1 127.0.0.1:6379> setnx lock value2 #試圖覆蓋lock的值,返回0表示失敗 (integer) 0 127.0.0.1:6379> get lock #獲取lock的值,驗證沒有被覆蓋 "value1" 127.0.0.1:6379> del lock #刪除lock的值,刪除成功 (integer) 1 127.0.0.1:6379> setnx lock value2 #再使用setnx命令設(shè)置,返回0表示成功 (integer) 1 127.0.0.1:6379> get lock #獲取lock的值,驗證設(shè)置成功加鎖:使用setnx key value命令,如果key不存在,設(shè)置value(加鎖成功)。如果已經(jīng)存在lock(也就是有客戶端持有鎖了),則設(shè)置失敗(加鎖失敗)。
解鎖:使用del命令,通過刪除鍵值釋放鎖。
?
不能死鎖
設(shè)置過期時間,到點數(shù)據(jù)刪除,避免導(dǎo)致如果一個客戶端持有鎖的期間突然崩潰了,就會導(dǎo)致無法解鎖,則其他人將無法拿到該鎖,鎖會一直存在,最終導(dǎo)致出現(xiàn)死鎖的現(xiàn)象。
?
鎖過期
有效時間設(shè)置多長,假如我的業(yè)務(wù)操作比有效時間長,我的業(yè)務(wù)代碼還沒執(zhí)行完就自動給我解鎖了,不就完蛋了嗎。
這個問題就有點棘手了,在網(wǎng)上也有很多討論,第一種解決方法就是靠程序員自己去把握,預(yù)估一下業(yè)務(wù)代碼需要執(zhí)行的時間,然后設(shè)置有效期時間比執(zhí)行時間長一些,保證不會因為自動解鎖影響到客戶端業(yè)務(wù)代碼的執(zhí)行。但是這并不是萬全之策,比如網(wǎng)絡(luò)抖動這種情況是無法預(yù)測的,也有可能導(dǎo)致業(yè)務(wù)代碼執(zhí)行的時間變長,所以并不安全。有一種方法比較靠譜一點,
就是給鎖續(xù)期。在Redisson框架實現(xiàn)分布式鎖的思路,就使用watchDog機制實現(xiàn)鎖的續(xù)期。當(dāng)加鎖成功后,同時開啟守護線程,默認有效期是30秒,每隔10秒就會給鎖續(xù)期到30秒,只要持有鎖的客戶端沒有宕機,就能保證一直持有鎖,直到業(yè)務(wù)代碼執(zhí)行完畢由客戶端自己解鎖,如果宕機了自然就在有效期失效后自動解鎖。
?
保證上鎖和解鎖都是同一個客戶端
key的值可以根據(jù)業(yè)務(wù)設(shè)置,比如是用戶中心使用的,可以命令為USER_REDIS_LOCK,value可以使用uuid保證唯一,用于標識加鎖的客戶端,保證加鎖和解鎖都是同一個客戶端。
每次解鎖可以先判斷鎖的value是不是當(dāng)前用戶,如果是,說明可以解鎖,如果不是,則不能解鎖,會導(dǎo)致誤解了其他人的鎖。
?
鎖的重入性
可重入鎖意思是在外層使用鎖之后,內(nèi)層仍然可以使用,那么可重入鎖的實現(xiàn)思路又是怎么樣的呢?在Redisson實現(xiàn)可重入鎖的思路,使用Redis的哈希表存儲可重入次數(shù),當(dāng)加鎖成功后,使用hset命令,value(重入次數(shù))則是1。
解鎖時,先判斷可重復(fù)次數(shù)是否大于0,大于0則減一,否則刪除鍵值,釋放鎖資源。
?
問題5.算法題
看到這算法題,我笑了,這不是力扣的第一題嗎,哈哈哈,幸好刷過。簡單方法大家都會寫,暴力操作,但是性能有影響,但是評論區(qū)有為大神寫的很巧妙,我就直接搬過來了。
題目:
給定一個整數(shù)數(shù)組 nums?和一個整數(shù)目標值 target,請你在該數(shù)組中找出 和為目標值 的那?兩個?整數(shù),并返回它們的數(shù)組下標。
你可以假設(shè)每種輸入只會對應(yīng)一個答案。但是,數(shù)組中同一個元素在答案里不能重復(fù)出現(xiàn)。
你可以按任意順序返回答案。
解法:
這個解法并不是從題目計算答案,而是從答案出發(fā),看需要什么數(shù)字。
public int[] twoSum(int[] nums, int target) {int[] indexs = new int[2];// 建立k-v ,一一對應(yīng)的哈希表HashMap<Integer,Integer> hash = new HashMap<Integer,Integer>();for(int i = 0; i < nums.length; i++){if(hash.containsKey(nums[i])){indexs[0] = i;indexs[1] = hash.get(nums[i]);return indexs;}// 將數(shù)據(jù)存入 key為補數(shù) ,value為下標hash.put(target-nums[i],i);}// // 雙重循環(huán) 循環(huán)極限為(n^2-n)/2 // for(int i = 0; i < nums.length; i++){// for(int j = nums.length - 1; j > i; j --){// if(nums[i]+nums[j] == target){// indexs[0] = i;// indexs[1] = j; // return indexs;// }// }// }return indexs;}?
總結(jié)
以上是生活随笔為你收集整理的《关于我横扫一线厂的那些面经》拼多多Java岗(附答案)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: BLDC无刷电机驱动板,foc驱动板,有
- 下一篇: 【附源码】Java计算机毕业设计安卓高速