深入了解Java并發(fā)AQS的獨(dú)占鎖模式
概述
稍微對(duì)并發(fā)源碼了解的朋友都知道,很多并發(fā)工具如ReentrantLock、CountdownLatch的實(shí)現(xiàn)都是依賴AQS, 全稱AbstractQueuedSynchronizer。
AQS是一種提供了原子式管理同步狀態(tài)、阻塞和喚醒線程功能以及隊(duì)列模型的簡單框架。一般來說,同步工具實(shí)現(xiàn)鎖的控制分為獨(dú)占鎖和共享鎖,而AQS提供了對(duì)這兩種模式的支持。
獨(dú)占鎖: 也叫排他鎖,即鎖只能由一個(gè)線程獲取,若一個(gè)線程獲取了鎖,則其他想要獲取鎖的線程只能等待,直到鎖被釋放。比如說寫鎖,對(duì)于寫操作,每次只能由一個(gè)線程進(jìn)行,若多個(gè)線程同時(shí)進(jìn)行寫操作,將很可能出現(xiàn)線程安全問題,比如jdk中的ReentrantLock。
共享鎖: 鎖可以由多個(gè)線程同時(shí)獲取,鎖被獲取一次,則鎖的計(jì)數(shù)器+1。比較典型的就是讀鎖,讀操作并不會(huì)產(chǎn)生副作用,所以可以允許多個(gè)線程同時(shí)對(duì)數(shù)據(jù)進(jìn)行讀操作,而不會(huì)有線程安全問題,當(dāng)然,前提是這個(gè)過程中沒有線程在進(jìn)行寫操作,比如ReadWriteLock和CountdownLatch。
本文重點(diǎn)講解下AQS對(duì)獨(dú)占鎖模式的支持。
自定義獨(dú)占鎖例子
首先我們自定義一個(gè)非常簡單的獨(dú)占鎖同步器demo, 來了解下AQS的使用。
public class ExclusiveLock implements Lock { // 同步器,繼承自AQS private static class Sync extends AbstractQueuedSynchronizer { // 重寫獲取鎖的方式 @Override protected boolean tryAcquire(int acquires) { assert acquires == 1; // cas的方式搶鎖 if(compareAndSetState(0, 1)) { // 設(shè)置搶占鎖的線程為當(dāng)前線程 setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } @Override protected boolean tryRelease(int releases) { assert releases == 1; if (getState() == 0) { throw new IllegalMonitorStateException(); }; //設(shè)置搶占鎖的線程為null setExclusiveOwnerThread(null); // 釋放鎖 setState(0); return true; } } private final Sync sync = new Sync(); @Override public void lock() { sync.acquire(1); } @Override public void unlock() { sync.release(1); } @Override public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); } @Override public boolean tryLock() { return sync.tryAcquire(1); } @Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(time)); } @Override public Condition newCondition() { return null; } }
這里是一個(gè)不可重入獨(dú)占鎖類,它使用值0表示未鎖定狀態(tài),使用值1表示鎖定狀態(tài)。
驗(yàn)證:
public static void main(String[] args) throws InterruptedException { ExclusiveLock exclusiveLock = new ExclusiveLock(); new Thread(() -> { try { exclusiveLock.lock(); System.out.println("thread1 get lock"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { exclusiveLock.unlock(); System.out.println("thread1 release lock"); } }).start(); new Thread(() -> { try { exclusiveLock.lock(); System.out.println("thread2 get lock"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { exclusiveLock.unlock(); System.out.println("thread2 release lock"); } }).start(); Thread.currentThread().join(); }
這樣一個(gè)很簡單的獨(dú)占鎖同步器就實(shí)現(xiàn)了,下面我們了解下它的核心機(jī)制。
核心原理機(jī)制
如果讓你設(shè)計(jì)一個(gè)獨(dú)占鎖你要考慮哪些方面呢?
- 線程如何表示搶占鎖資源成功呢?是不是可以個(gè)狀態(tài)state標(biāo)記,state=1表示有線程持有鎖,其他線程等待。
- 其他搶鎖失敗的線程維護(hù)在哪里呢?是不是要引入一個(gè)隊(duì)列維護(hù)獲取鎖失敗的線程隊(duì)列?
- 那如何讓線程實(shí)現(xiàn)阻塞呢?還記得LockSupport.park和unpark可以實(shí)現(xiàn)線程的阻塞和喚醒嗎?
這些問題我們可以再AQS的數(shù)據(jù)結(jié)構(gòu)和源碼中統(tǒng)一找到答案。
AQS內(nèi)部維護(hù)了一個(gè)volatile int state(代表共享資源)和一個(gè)FIFO線程等待隊(duì)列(多線程爭(zhēng)用資源被阻塞時(shí)會(huì)進(jìn)入此隊(duì)列)。
以上面?zhèn)€的例子為例,state初始化為0,表示未鎖定狀態(tài)。A線程lock()時(shí),會(huì)調(diào)用AQS的acquire方法,acquire會(huì)調(diào)用子類重寫的tryAcquire()方法,通過cas的方式搶占鎖。此后,其他線程再tryAcquire()時(shí)就會(huì)失敗,進(jìn)入到CLH隊(duì)列中,直到A線程unlock()即釋放鎖為止,即將state還原為0,其它線程才有機(jī)會(huì)獲取該鎖。
AQS作為一個(gè)抽象方法,提供了加鎖、和釋放鎖的框架,這里采用的模板方模式,在上面中提到的tryAcquire
、tryRelease
就是和獨(dú)占模式相關(guān)的模板方法,其他的模板方法和共享鎖模式或者Condition相關(guān),本文不展開討論。
方法名 | 描述 |
---|---|
protected boolean tryAcquire(int arg) | 獨(dú)占方式。arg為獲取鎖的次數(shù),嘗試獲取資源,成功則返回True,失敗則返回False。 |
protected boolean tryRelease(int arg) | 獨(dú)占方式。arg為釋放鎖的次數(shù),嘗試釋放資源,成功則返回True,失敗則返回False。 |
源碼解析
上圖是AQS的類結(jié)構(gòu)圖,其中標(biāo)紅部分是組成AQS的重要成員變量。
成員變量
state共享變量
AQS中里一個(gè)很重要的字段state,表示同步狀態(tài),是由volatile修飾的,用于展示當(dāng)前臨界資源的獲鎖情況。通過getState(),setState(),compareAndSetState()三個(gè)方法進(jìn)行維護(hù)。
關(guān)于state的幾個(gè)要點(diǎn):
- 使用volatile修飾,保證多線程間的可見性。
- getState()、setState()、compareAndSetState()使用final修飾,限制子類不能對(duì)其重寫。
- compareAndSetState()采用樂觀鎖思想的CAS算法,保證原子性操作。
CLH隊(duì)列(FIFO隊(duì)列)
AQS里另一個(gè)重要的概念就是CLH隊(duì)列,它是一個(gè)雙向鏈表隊(duì)列,其內(nèi)部由head和tail分別記錄頭結(jié)點(diǎn)和尾結(jié)點(diǎn),隊(duì)列的元素類型是Node。
private transient volatile Node head; private transient volatile Node tail;
Node的結(jié)構(gòu)如下:
static final class Node { //共享模式下的等待標(biāo)記 static final Node SHARED = new Node(); //獨(dú)占模式下的等待標(biāo)記 static final Node EXCLUSIVE = null; //表示當(dāng)前結(jié)點(diǎn)已取消調(diào)度。當(dāng)timeout或被中斷(響應(yīng)中斷的情況下),會(huì)觸發(fā)變更為此狀態(tài),進(jìn)入該狀態(tài)后的結(jié)點(diǎn)將不會(huì)再變化。 static final int CANCELLED = 1; //表示后繼結(jié)點(diǎn)在等待當(dāng)前結(jié)點(diǎn)喚醒。后繼結(jié)點(diǎn)入隊(duì)時(shí),會(huì)將前繼結(jié)點(diǎn)的狀態(tài)更新為SIGNAL。 static final int SIGNAL = -1; //表示結(jié)點(diǎn)等待在Condition上,當(dāng)其他線程調(diào)用了Condition的signal()方法后,CONDITION狀態(tài)的結(jié)點(diǎn)將從等待隊(duì)列轉(zhuǎn)移到同步隊(duì)列中,等待獲取同步鎖。 static final int CONDITION = -2; //共享模式下,前繼結(jié)點(diǎn)不僅會(huì)喚醒其后繼結(jié)點(diǎn),同時(shí)也可能會(huì)喚醒后繼的后繼結(jié)點(diǎn)。 static final int PROPAGATE = -3; //狀態(tài),包括上面的四種狀態(tài)值,初始值為0,一般是節(jié)點(diǎn)的初始狀態(tài) volatile int waitStatus; //上一個(gè)節(jié)點(diǎn)的引用 volatile Node prev; //下一個(gè)節(jié)點(diǎn)的引用 volatile Node next; //保存在當(dāng)前節(jié)點(diǎn)的線程引用 volatile Thread thread; //condition隊(duì)列的后續(xù)節(jié)點(diǎn) Node nextWaiter; }
注意,waitSstatus負(fù)值表示結(jié)點(diǎn)處于有效等待狀態(tài),而正值表示結(jié)點(diǎn)已被取消。所以源碼中很多地方用>0、<0來判斷結(jié)點(diǎn)的狀態(tài)是否正常。
exclusiveOwnerThread
AQS通過繼承AbstractOwnableSynchronizer類,擁有的屬性。表示獨(dú)占模式下同步器持有的線程。
獨(dú)占鎖獲取acquire(int)
acquire(int)是獨(dú)占模式下線程獲取共享資源的入口方法。
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
方法的整體流程如下:
- tryAcquire()嘗試直接去獲取資源,如果成功則直接返回。
- 如果失敗則調(diào)用addWaiter()方法把當(dāng)前線程包裝成Node(狀態(tài)為EXCLUSIVE,標(biāo)記為獨(dú)占模式)插入到CLH隊(duì)列末尾。
- acquireQueued()方法使線程阻塞在等待隊(duì)列中獲取資源,一直獲取到資源后才返回,如果在整個(gè)等待過程中被中斷過,則返回true,否則返回false。
- 線程在等待過程中被中斷過,它是不響應(yīng)的。只有線程獲取到資源后,acquireQueued返回true,響應(yīng)中斷。
tryAcquire(int)
此方法嘗試去獲取獨(dú)占資源。如果獲取成功,則直接返回true,否則直接返回false。
//直接拋出異常,這是由子類進(jìn)行實(shí)現(xiàn)的方法,體現(xiàn)了模板模式的思想 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
AQS只是一個(gè)框架,具體資源的獲取/釋放方式交由自定義同步器去實(shí)現(xiàn),比如公平鎖有公平鎖的獲取方式,非公平鎖有非公平鎖的獲取方式。
addWaiter(Node)
此方法用于將當(dāng)前線程加入到等待隊(duì)列的隊(duì)尾。
// 將線程封裝成一個(gè)節(jié)點(diǎn),放入同步隊(duì)列的尾部 private Node addWaiter(Node mode) { // 當(dāng)前線程封裝成同步隊(duì)列的一個(gè)節(jié)點(diǎn)Node Node node = new Node(Thread.currentThread(), mode); // 這個(gè)節(jié)點(diǎn)需要插入到原尾節(jié)點(diǎn)的后面,所以我們?cè)谶@里先記下原來的尾節(jié)點(diǎn) Node pred = tail; // 判斷尾節(jié)點(diǎn)是否為空,若為空表示隊(duì)列中還沒有節(jié)點(diǎn),則不執(zhí)行以下步驟 if (pred != null) { // 記錄新節(jié)點(diǎn)的前一個(gè)節(jié)點(diǎn)為原尾節(jié)點(diǎn) node.prev = pred; // 將新節(jié)點(diǎn)設(shè)置為新尾節(jié)點(diǎn),使用CAS操作保證了原子性 if (compareAndSetTail(pred, node)) { // 若設(shè)置成功,則讓原來的尾節(jié)點(diǎn)的next指向新尾節(jié)點(diǎn) pred.next = node; return node; } } // 若以上操作失敗,則調(diào)用enq方法繼續(xù)嘗試(enq方法見下面) enq(node); return node; } private Node enq(final Node node) { // 使用死循環(huán)不斷嘗試 for (;;) { // 記錄原尾節(jié)點(diǎn) Node t = tail; // 若原尾節(jié)點(diǎn)為空,則必須先初始化同步隊(duì)列,初始化之后,下一次循環(huán)會(huì)將新節(jié)點(diǎn)加入隊(duì)列 if (t == null) { // 使用CAS設(shè)置創(chuàng)建一個(gè)默認(rèn)的節(jié)點(diǎn)作為首屆點(diǎn) if (compareAndSetHead(new Node())) // 首尾指向同一個(gè)節(jié)點(diǎn) tail = head; } else { // 以下操作與addWaiter方法中的if語句塊內(nèi)一致 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
它的執(zhí)行過程大致可以總結(jié)為:將新線程封裝成一個(gè)節(jié)點(diǎn),加入到同步隊(duì)列的尾部,若同步隊(duì)列為空,則先在其中加入一個(gè)默認(rèn)的節(jié)點(diǎn),再進(jìn)行加入;若加入失敗,則使用死循環(huán)(也叫自旋)不斷嘗試,直到成功為止。
acquireQueued(Node, int)
通過tryAcquire()和addWaiter(),該線程獲取資源失敗,已經(jīng)被放入等待隊(duì)列尾部了。接下來要干嘛呢?
進(jìn)入等待狀態(tài)休息,直到其他線程徹底釋放資源后喚醒自己,自己再拿到資源,然后就可以去干自己想干的事了??梢韵胂蟪舍t(yī)院排隊(duì)拿號(hào),在等待隊(duì)列中排隊(duì)拿號(hào)(中間沒其它事干可以休息),直到拿到號(hào)后再返回。
final boolean acquireQueued(final Node node, int arg) { //標(biāo)記是否成功拿到資源 boolean failed = true; try { //標(biāo)記等待過程中是否被中斷過 boolean interrupted = false; //“自旋”! for (;;) { //拿到前驅(qū) final Node p = node.predecessor(); //如果前驅(qū)是head,即該結(jié)點(diǎn)已成老二,那么便有資格去嘗試獲取資源(可能是老大釋放完資源喚醒自己的,當(dāng)然也可能被interrupt了)。 if (p == head && tryAcquire(arg)) { //拿到資源后,將head指向該結(jié)點(diǎn)。所以head所指的標(biāo)桿結(jié)點(diǎn),就是當(dāng)前獲取到資源的那個(gè)結(jié)點(diǎn)或null。 setHead(node); // setHead中node.prev已置為null,此處再將head.next置為null,就是為了方便GC回收以前的head結(jié)點(diǎn)。也就意味著之前拿完資源的結(jié)點(diǎn)出隊(duì)了! p.next = null; // 成功獲取資源 failed = false; //返回等待過程中是否被中斷過 return interrupted; } //如果自己可以休息了,就通過park()進(jìn)入waiting狀態(tài),直到被unpark()。 // 如果不可中斷的情況下被中斷了,那么會(huì)從park()中醒過來,發(fā)現(xiàn)拿不到資源,從而繼續(xù)進(jìn)入park()等待。 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) //如果等待過程中被中斷過,哪怕只有那么一次,就將interrupted標(biāo)記為true interrupted = true; } } finally { if (failed) // 如果等待過程中沒有成功獲取資源(如timeout,或者可中斷的情況下被中斷了),那么取消結(jié)點(diǎn)在隊(duì)列中的等待。 cancelAcquire(node); } }
小結(jié)一下:讓線程在同步隊(duì)列中阻塞,直到它成為頭節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn),被頭節(jié)點(diǎn)對(duì)應(yīng)的線程喚醒,然后開始獲取鎖,若獲取成功才會(huì)從方法中返回。這個(gè)方法會(huì)返回一個(gè)boolean值,表示這個(gè)正在同步隊(duì)列中的線程是否被中斷。
shouldParkAfterFailedAcquire()
此方法主要用于檢查狀態(tài),看看自己是否真的可以去休息了。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { //拿到前驅(qū)的狀態(tài) int ws = pred.waitStatus; if (ws == Node.SIGNAL) //如果已經(jīng)告訴前驅(qū)拿完號(hào)后通知自己一下,那就可以安心休息了 return true; if (ws > 0) { /* * 如果前驅(qū)放棄了,那就一直往前找,直到找到最近一個(gè)正常等待的狀態(tài),并排在它的后邊。 * 注意:那些放棄的結(jié)點(diǎn),由于被自己“加塞”到它們前邊,它們相當(dāng)于形成一個(gè)無引用鏈,稍后就會(huì)被保安大叔趕走了(GC回收)! */ do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { //如果前驅(qū)正常,那就把前驅(qū)的狀態(tài)設(shè)置成SIGNAL,告訴它拿完號(hào)后通知自己一下。有可能失敗,人家說不定剛剛釋放完呢! compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
整個(gè)流程中,如果前驅(qū)結(jié)點(diǎn)的狀態(tài)不是SIGNAL,那么自己就不能安心去休息,需要去找個(gè)安心的休息點(diǎn),同時(shí)可以再嘗試下看有沒有機(jī)會(huì)輪到自己拿號(hào)。
parkAndCheckInterrupt()
這個(gè)方法是真正實(shí)現(xiàn)線程阻塞,休息的地方。
private final boolean parkAndCheckInterrupt() { // 調(diào)用park()使線程進(jìn)入waiting狀態(tài) LockSupport.park(this); //調(diào)用park()使線程進(jìn)入waiting狀態(tài) return Thread.interrupted(); }
park()會(huì)讓當(dāng)前線程進(jìn)入waiting狀態(tài)。在此狀態(tài)下,有兩種途徑可以喚醒該線程:1)被unpark();2)被interrupt()。
selfInterrupt()
static void selfInterrupt() { Thread.currentThread().interrupt(); }
中斷線程,設(shè)置線程的中斷位true。因?yàn)閜arkAndCheckInterrupt方法中的Thread.interrupted()會(huì)清楚中斷標(biāo)記,需要在selfInterrupt方法中將中斷補(bǔ)上。
整個(gè)流程可以用下面一個(gè)圖來說明。
獨(dú)占鎖釋放release(int)
release(int)
是獨(dú)占模式下線程釋放共享資源的入口。它會(huì)釋放指定量的資源,如果徹底釋放了(即state=0),它會(huì)喚醒等待隊(duì)列里的其他線程來獲取資源。
public final boolean release(int arg) { // 上邊自定義的tryRelease如果返回true,說明該鎖沒有被任何線程持有 if (tryRelease(arg)) { // 獲取頭結(jié)點(diǎn) Node h = head; // 頭結(jié)點(diǎn)不為空并且頭結(jié)點(diǎn)的waitStatus不是初始化節(jié)點(diǎn)情況,解除線程掛起狀態(tài) if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
這里的判斷條件為什么是h != null && h.waitStatus != 0?
- h == null Head還沒初始化。初始情況下,head == null,第一個(gè)節(jié)點(diǎn)入隊(duì),Head會(huì)被初始化一個(gè)虛擬節(jié)點(diǎn)。所以說,這里如果還沒來得及入隊(duì),就會(huì)出現(xiàn)head == null 的情況。
- h != null && waitStatus == 0 表明后繼節(jié)點(diǎn)對(duì)應(yīng)的線程仍在運(yùn)行中,不需要喚醒。
- h != null && waitStatus < 0 表明后繼節(jié)點(diǎn)可能被阻塞了,需要喚醒。
tryRelease(int)
tryRelease是一個(gè)模板方法,由子類實(shí)現(xiàn),定義釋放鎖的邏輯。
protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
因?yàn)檫@是獨(dú)占模式,該線程來釋放資源,那么它肯定已經(jīng)拿到獨(dú)占資源了,直接減掉相應(yīng)量的資源即可(state-=arg),也不需要考慮線程安全的問題。但要注意它的返回值,上面已經(jīng)提到了,release()是根據(jù)tryRelease()的返回值來判斷該線程是否已經(jīng)完成釋放掉資源了!所以自義定同步器在實(shí)現(xiàn)時(shí),如果已經(jīng)徹底釋放資源(state=0),要返回true,否則返回false。
unparkSuccessor(Node)
private void unparkSuccessor(Node node) { // 獲取頭結(jié)點(diǎn)waitStatus int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); // 獲取當(dāng)前節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn) Node s = node.next; // 如果下個(gè)節(jié)點(diǎn)是null或者下個(gè)節(jié)點(diǎn)被cancelled,就找到隊(duì)列最開始的非cancelled的節(jié)點(diǎn) if (s == null || s.waitStatus > 0) { s = null; // 就從尾部節(jié)點(diǎn)開始找,到隊(duì)首,找到隊(duì)列第一個(gè)waitStatus<0的節(jié)點(diǎn)。 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } // 如果當(dāng)前節(jié)點(diǎn)的下個(gè)節(jié)點(diǎn)不為空,而且狀態(tài)<=0,就把當(dāng)前節(jié)點(diǎn)unpark if (s != null) LockSupport.unpark(s.thread); }
為什么要從后往前找第一個(gè)非Cancelled的節(jié)點(diǎn)呢?
之前的addWaiter方法:
private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; }
我們從這里可以看到,節(jié)點(diǎn)入隊(duì)并不是原子操作,也就是說,node.prev = pred; compareAndSetTail(pred, node) 這兩個(gè)地方可以看作Tail入隊(duì)的原子操作,但是此時(shí)pred.next = node;還沒執(zhí)行,如果這個(gè)時(shí)候執(zhí)行了unparkSuccessor方法,就沒辦法從前往后找了,所以需要從后往前找。還有一點(diǎn)原因,在產(chǎn)生CANCELLED狀態(tài)節(jié)點(diǎn)的時(shí)候,先斷開的是Next指針,Prev指針并未斷開,因此也是必須要從后往前遍歷才能夠遍歷完全部的Node。
綜上所述,如果是從前往后找,由于極端情況下入隊(duì)的非原子操作和CANCELLED節(jié)點(diǎn)產(chǎn)生過程中斷開Next指針的操作,可能會(huì)導(dǎo)致無法遍歷所有的節(jié)點(diǎn)。所以,喚醒對(duì)應(yīng)的線程后,對(duì)應(yīng)的線程就會(huì)繼續(xù)往下執(zhí)行。
總結(jié)
本文主要講解了AQS的獨(dú)占模式,最關(guān)鍵的是acquire()和release這兩個(gè)和獨(dú)占息息相關(guān)的方法,同時(shí)通過一個(gè)自定義簡單的demo幫助大家深入淺出的理解,其實(shí)AQS的功能不限于此,內(nèi)容很多,這里就先分享一個(gè)最基礎(chǔ)獨(dú)占鎖的原理,希望對(duì)大家有幫助。
以上就是深入了解Java并發(fā)AQS的獨(dú)占鎖模式的詳細(xì)內(nèi)容,更多關(guān)于Java并發(fā)AQS獨(dú)占鎖模式的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
使用java + selenium + OpenCV破解騰訊防水墻滑動(dòng)驗(yàn)證碼功能
這篇文章主要介紹了使用java + selenium + OpenCV破解騰訊防水墻滑動(dòng)驗(yàn)證碼,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-11-11基于Map的computeIfAbsent的使用場(chǎng)景和使用方式
這篇文章主要介紹了基于Map的computeIfAbsent的使用場(chǎng)景和使用方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09Java面試題之HashMap 的 hash 方法原理是什么
那天,小二去蔚來面試,面試官老王一上來就問他:HashMap 的 hash 方法的原理是什么?當(dāng)時(shí)就把裸面的小二給蚌埠住了,這篇文章將詳細(xì)解答該題目2021-11-11Jenkins+maven持續(xù)集成的實(shí)現(xiàn)
這篇文章主要介紹了Jenkins+maven持續(xù)集成的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04關(guān)于MVC的dao層、service層和controller層詳解
這篇文章主要介紹了關(guān)于MVC的dao層、service層和controller層詳解,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-02-02一文看懂springboot實(shí)現(xiàn)短信服務(wù)功能
項(xiàng)目中的短信服務(wù)基本上上都會(huì)用到,簡單的注冊(cè)驗(yàn)證碼,消息通知等等都會(huì)用到。這篇文章主要介紹了springboot 實(shí)現(xiàn)短信服務(wù)功能,需要的朋友可以參考下2019-10-10Java報(bào)錯(cuò):UnsupportedOperationException in Collection
在Java編程中,UnsupportedOperationException是一種常見的運(yùn)行時(shí)異常,通常在試圖對(duì)不支持的操作執(zhí)行修改時(shí)發(fā)生,它表示當(dāng)前操作不被支持,本文將深入探討UnsupportedOperationException的產(chǎn)生原因,并提供具體的解決方案和最佳實(shí)踐,需要的朋友可以參考下2024-06-06Springboot實(shí)現(xiàn)接口傳輸加解密的步驟詳解
這篇文章主要給大家詳細(xì)介紹了Springboot實(shí)現(xiàn)接口傳輸加解密的操作步驟,文中有詳細(xì)的圖文解釋和代碼示例供大家參考,對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2023-09-09