《JAVA併發程式設計實戰》取消和關閉

sayWhat_sayHello發表於2018-10-28

引言

java沒有提供任何機制來安全的終止執行緒。但它提供了一種協作機制:中斷。

這種協作式的方法是必要的,我們很少希望某個任務、執行緒或服務立即停止,因為這種立即停止會導致資料結構處於不一致的狀態。

任務取消

取消某個操作的原因可能是:

  • 使用者請求取消:點選取消按鈕等操作
  • 有時間限制的操作
  • 應用程式事件:例如應用程式對某個問題空間進行分解並搜尋,從而使不同的任務可以搜尋不同區域,當其中一個任務找到了解決方案時,所有其他仍在搜尋的任務都要被取消。
  • 錯誤:爬蟲下載資源時,磁碟滿,可能所有任務都將被取消
  • 關閉:當一個程式或服務關閉時,必須對正在處理和等待處理的工作執行某種操作。

在Java中沒有一種安全的搶佔方法停止執行緒,因此也就沒有安全的搶佔方法來停止任務。

其中一種協作機制能設定某個“已請求取消”標識,而任務將定期檢視該標誌。如果設定了這個標誌,那麼任務將提前結束。

public class PrimeGenerator implements Runnable {
    private final List<BigInteger> primes = new ArrayList<>();
    private volatile boolean cancelled;
    
    public void run() {
        BigInteger p = BigInteger.ONE;
        while(!cancelled) {
            p = p.nextProbablePrime();
            synchronized (this){
                primes.add(p);
            }
        }
    }
    
    public void cancel() {
        cancelled = true;
    }
    
    public synchronized List<BigInteger> get(){
        return new ArrayList<BigInteger>(primes);
    }
}


List<BigInteger> aSecondOfPrimes() throws InterruptedException{
    PrimeGenerator generator = new PrimeGenerator();
    new Thread(generator).start();
    try {
        SECONDS.sleep(1);
    } finally {
        generator.cancel();
    }
    return generator.get();
}

一個可取消的任務必須擁有取消策略,在策略中詳細的定義取消操作的"How",“When"以及"What”.即其他程式碼如何取消該任務,任務在何時(When)檢查是否已經請求了取消,以及在響應取消請求時應該執行哪些操作。

中斷

PrimeGenerator中取消的機制最終會使得搜尋素數的任務退出,但這退出過程中需要花費一定的時間。然而,如果使用這個方法的任務呼叫了一個阻塞方法,例如BlockingQueue.put,那麼可能會產生一個更嚴重的問題——任務可能永遠不會檢查取消標識,因此永遠不會結束。

class BrokenPrimeProducer extends Thread{
    private final BlockingQueue<BigInteger> queue;
    private volatile boolean cancelled = false;
    
    BrokenPrimeProducer(BlockingQueue<BigInteger> queue){
        this.queue = queue;
    }
    
    public void run(){
        try{
            BigInteger p = BigInteger.ONE;
            while(!cancelled){
                queue.put(p = p.nextProbablePrime());
            }
        } catch(InterruptedException consumed){
            
        }
    }
    
    public void cancel() {
        cancelled = true;
    }
}

void consumePrimes() throws InterruptedException{
    BlockingQueue<BigInteger> primes = ...;
    BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
    producer.start();
    try{
        while(needMorePrimes()){
            consume(primes.take());
        } 
    } finally {
        producer.cancel();
    }
}

生產者執行緒生產素數,並將它們放入到一個阻塞佇列。如果生產者的速度超過了消費者的處理速度,佇列將被填滿,put方法也會阻塞。當生產者在put中阻塞時,如果消費者希望取消生產者任務,那麼在它呼叫cancel方法設定cancelled標誌時,生產者卻永遠不能檢查這個標誌,因為它無法從阻塞的put方法中恢復過來。

每個執行緒都有一個boolean型別的中斷狀態。當中斷執行緒時,將這個中斷狀態設定為true。在Thread中包含了中斷執行緒以及查詢執行緒中斷狀態的方法。

public class Thread{
    public void interrupt(){}
    public void boolean isInterrupted(){}
    public static boolean interrupted(){}
}

interrupt中斷目標執行緒,isInterrupted方法返回目標執行緒的中斷狀態,靜態的interrupted方法清除當前執行緒的中斷狀態。

阻塞庫方法,例如Thread.sleep和Object.wait等,都會檢查執行緒何時中斷,且在發現中斷時提前返回,他們在響應中斷時執行的操作包括:清除中斷狀態,丟擲InterruptedException,表示阻塞操作由於中斷而提前結束。JVM並不能保證阻塞方法檢測到中斷的速度,但在實際情況中響應速度還是非常快的。

當執行緒中非阻塞狀態下中斷時,它的中斷狀態將被設定,然後根據被取消的操作來檢查中斷狀態以判斷髮生了中斷。通過這樣的方法,中斷操作將變得有粘性——如果不觸發InterruptedException,那麼中斷狀態將一直保持,直到明確的清除中斷狀態。

呼叫interrupt並不意味著立即停止目標執行緒正在進行的工作,而只是傳遞了請求中斷的訊息。

對中斷操作的正確理解是:它並不會真正的中斷一個正在執行的執行緒,而只是發出中斷請求,然後由執行緒在下一個合適的時刻中斷自己。

在使用靜態的interrupted時應該小心,因為它會清除當前執行緒的中斷狀態。如果在呼叫interrupted時返回了true,那麼除非要遮蔽這個中斷,否則必須對它進行處理——可以丟擲InterruptedException,也可以再次呼叫interrupt來恢復中斷狀態

通常,中斷是實現取消的最合理方式

class PrimeProducer extends Thread{
    private final BlockingQueue<BigInteger> queue;
    PrimeProducer(BlockingQueue<BigInteger> queue){
        this.queue = queue;
    }
    
    public void run(){
        try{
            BigInteger p = BigInteger.ONE;
            while(!Thread.currentThread().isInterrupted()){
                queue.put(p = p.nextProbablePrime());
            } 
        } catch(InterruptedException consumed) {
            /* 允許執行緒退出 */
        }
    }
    
    public void cancel(){
        interrupt();
    }
}

中斷策略

中斷策略規定執行緒如何解釋某個中斷請求——當發現中斷請求時,應該做哪些工作,哪些工作單元對於中斷來說是原子操作,以及以多快的速度來響應中斷。

最合理的中斷策略是以某種形式的執行緒級取消操作或者服務級取消操作:儘快退出,必要時進行清理,通知某個所有者該執行緒已經退出。此外還可以建立其他的中斷策略,例如暫停服務或重新開始服務。

區分任務和執行緒對中斷的反應非常重要。一箇中斷請求可以有一個或者多個接受者——中斷執行緒池中的某個工作者執行緒,同時意味著“取消當前任務”和“關閉工作者執行緒”。

任務不會在其自己擁有的執行緒中執行,而是在某個服務擁有的執行緒中執行。對於非執行緒所有者的程式碼來說(例如,對於執行緒池來說,如何線上程池實現以外的程式碼),應該小心的儲存中斷狀態,這樣擁有執行緒的程式碼才能對中斷做出相應。

執行緒應該只能由其所有者中斷,所有者可以將執行緒的中斷策略資訊封裝到某個合適的取消機制中,例如關閉方法。

由於每個執行緒擁有各自的中斷策略,因此除非你知道中斷對該執行緒的含義,否則就不應該中斷這個執行緒

響應中斷

在呼叫可中斷的阻塞函式時,例如Thread.sleep或BolckingQueue.put等,有兩種實用策略可以處理InterruptedException:

  • 傳遞異常
  • 恢復中斷狀態

將InterruptedException傳遞給呼叫者:

BlockingQueue<Task> queue;
public Task getNextTask() throws InterruptedException{
    return queue.take();
}

如果不想或者無法傳遞InterruptedException(或許通過Runnable來定義任務),那麼需要尋找另一種方式來儲存中斷請求。一種標準的方法就是通過再次呼叫interrupt來恢復中斷狀態。

只有實現了執行緒中斷策略的程式碼才可以遮蔽中斷請求,在常規的任務和庫程式碼中都不應該遮蔽中斷請求。

對於不支援取消但仍可以呼叫可中斷阻塞方法的操作,他們必須在迴圈中呼叫這些方法,並在發現中斷後重新嘗試。在這種情況下,他們應該在本地儲存中斷狀態,並在返回前回復狀態而不是在捕獲InterruptedException時恢復狀態。如果過早的設定中斷狀態,就可能引起無限迴圈,因為大多數可中斷的阻塞方法都會在入口處檢查中斷狀態,並且當發現該狀態已被設定時會立即丟擲InterruptedException(通常,可中斷的方法會在阻塞或進行重要的工作前首先檢查中斷,從而儘快的響應中斷)

不可取消的任務在退出前恢復中斷

public Task getNextTask(BlockingQueue<Task> queue) {
    boolean interrupted = false;
    try {
        while(true){
            try{
                return queue.take();    
            }catch(InterruptedException e) {
                interrupted = true;
            }
        } 
    } finally{
        if(interrupted){
            Thread.currentThread().interrupt();
        }
    }
}

如果程式碼不會呼叫可中斷的阻塞方法,那麼仍然可以通過在任務程式碼中輪詢當前執行緒的中斷狀態來響應中斷。

示例:計時執行

private static final ScheduledExecutorService cancelExec = ...;
public static void timedRun(Runnable r,long timeout,TimeUnit unit) {
    final Thread taskThread = Thread.currentThread();
    cancelExec.schedule(new Runnable(){
        public void run(){
            taskThread.interrupt();
        }
    },timeout,unit);
    r.run();
}

這是一種非常簡單的方法,然而卻破壞了以下規則:在中斷執行緒之前,應該瞭解它的中斷策略。由於timedRun可以從任意一個執行緒中呼叫,因此它無法知道這個呼叫執行緒的中斷策略。如果任務在超時之前完成,那麼中斷timedRun所線上程的取消任務將在timedRun返回到呼叫者後啟動。

而且,如果任務不響應中斷,那麼timedRun會在任務結束時才返回,此時可能已經超過了指定的時限。

public static void timedRun(final Runnable r,long timeout,TimeUnit unit) throws InterruptedException{
    class RethrowableTask implements Runnable{
        private volatile Throwable t;
        public void run(){
            try{
                r.run();
            } catch(Throwable t){
                this.t = t;
            }
        }
        void rethrow(){
            if(t != null) {
                throw launderThrowable(t);
            }
        }    
    }
    
    RethrowableTask task = new RethrowableTask();
    final Thread taskThread = new Thread(task);
    taskThread.start();
    cancelExec.schedule(new Runnable(){
        public void run() {
            taskThread.interrupt();
        }
    },timeout,unit);
    taskThread.join(unit.toMillis(timeout));
    task.rethrow();
    
    
}

這個示例的程式碼解決了前面示例中的問題,但由於它依賴於一個限時的join,因此存在著join的不足:無法知道執行控制是因為執行緒正常退出而返回還是因為join超時而返回。

通過Future來實現取消

ExecutorService.submit返回一個Future來描述任務。Future有一個cancel方法,該方法帶有一個boolean型別的引數mayInterruptIfRunning,表示取消操作是否成功(這只是表示任務是否能夠接收中斷,而不是表示任務能否檢測並處理中斷)。如果該引數為true並且任務當前正在某個執行緒中執行,那麼這個執行緒能被中斷。如果這個引數為false,那麼意味著“若任務還沒有啟動,那就不要啟動它”,這種方式應該用於那些不處理中斷的任務中。

除非你清楚執行緒的中斷策略,否則不要中斷執行緒。當嘗試取消某個任務時,不宜直接中斷執行緒池。

public static void timeRun(Runnable r,long timeout,TimeUnit unit) throws InterruptedException{
    Future<?> task = taskExec.submit(r);
    try{
        task.get(timeout,unit);
    } catch(TimeoutException e){
        //接下來任務將被取消
    } catch(ExecutionException e){
        throw launderThrowable(e.getCauese());
    } finally {
        //如果任務已經結束,那麼執行取消操作也不會帶來任何影響
        //如果任務正在執行,那麼將被打斷
        task.cancel(true);
    }
}

當Future.get丟擲InterruptedException或TimeoutException 時,如果你知道不再需要結果,那麼就可以呼叫Future.cancel取消任務

處理不可中斷的阻塞

並非所有的可阻塞方法或者阻塞機制都能響應中斷;如果一個執行緒由於執行同步的Socket I/O或者等待獲得內建鎖而阻塞,那麼中斷請求只能設定執行緒的中斷狀態,除此之外沒有其他任何作用。

由於執行不可中斷操作而被阻塞的執行緒,可以使用類似於中斷的手段來停止這些執行緒,但這要求我們必須知道執行緒阻塞的原因。

  1. java.io包中的同步Socket I/O。在伺服器應用程式中,最常見的阻塞I/O形式就是對套接字進行讀取和寫入。雖然InputStream和OutputStream中的read和write等方法都不會響應中斷,但是通過關閉底層的套接字,可以使得由於執行read或write等方法被阻塞的執行緒丟擲一個SocketException
  2. java.io包中的同步I/O。當中斷一個正在InterruptibleChannel上等待的執行緒時,將丟擲ClosedByInterruptException並關閉鏈路。當關閉一個InterruptibleChannel時,將導致所有在鏈路操作上阻塞的執行緒都丟擲AsynchronousCloseException。大多數的Channel都實現了InterruptibleChannel.
  3. Selector的非同步I/O。如果一個執行緒在呼叫Selector.select方法時阻塞了,那麼呼叫close或wakeup方法會使執行緒丟擲ClosedSelectorException並提前返回。
  4. 獲取某個鎖。如果一個執行緒由於等待某個內建鎖而阻塞,那麼將無法響應中斷。在Lock類中提供了lockInterruptibly方法,該方法允許在等待一個鎖的同時仍能響應中斷。

下面展示的是如何封裝非標準的取消操作。

public class ReaderThread extends Thread{
    private final Socket socket;
    private final InputStream in;
    public ReaderThread(Socket socket) throws IOException{
        this.socket = socket;
        this.in = socket.getInputStream();
    }
    
    public void interrupt(){
        try{
            socket.close();
        }catch(IOException ignored){
            
        }finally{
            super.interrupt();
        }
        
    }
    
    
    public void run(){
        try{
            byte[] buf = new byte[1024];
            while(true){
                int count = in.read(buf);
                if(count < 0) {
                    break;
                } else if(count > 0) {
                    processBuffer(buf,count);
                }
            }
        } catch(IOException e){
            /*允許執行緒退出*/
        }
    }
}

採用newTaskFor封裝非標準的取消

當把一個Callable提交給ExecutorService時,submit方法會返回一個Future,我們可以通過這個Future來取消任務。newTaskFor是一個工廠方法,它將建立Future來代表任務。newTaskFor還能返回一個RunnableFuture介面,該介面擴充了Future和Runnable(並由FutureTask實現)。

通過定製表示任務的Future可以改變Future.cancel的行為。例如定製的取消程式碼可以實現日誌記錄或者收集取消操作的統計資訊,以及取消一些不響應中斷的操作。通過改寫interrupt方法,ReaderThread可以取消基於套接字的執行緒。同樣,通過改寫任務的Future,cancel方法也可以實現類似的功能。

public interface CancellableTask<T> extends Callable<T>{
    void cancel();
    RunnableFuture<T> newTask();
}

public class CancellingExecutor extends ThreadPoolExecutor{
    protected<T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
        if(callable instanceof CancellableTask){
            return ((CancellableTask<T>)callable).newTask();
        } else {
            return super.newTaskFor(callable);
        }
    }
}

public abstract class SocketUsingTask<T> implements CancellableTask<T> {
    private Socket socket;
    protected synchronized void setSocket(Socket s){
        socket = s;
    }
    
    public synchronized void cancel(){
        try{
            if(socket!=null){
                socket.close();
            } 
        } catch(IOException ingnored){}
    }
    
    public RunnableFuture<T> newTask(){
        return new FutureTask<T>(this){
            public boolean cancel(boolean mayInterruptIfRunning){
                try{
                    SocketUsingTask.this.cancel();
                } finally {
                    return super.cancel(mayInterruptIfRunning);
                }
            }
        };
    }
}

停止基於執行緒的服務

應用程式通常會建立擁有多個執行緒的服務,例如執行緒池,並且這些服務的生命週期通常比建立它們的生命週期更長。如果應用程式準備退出,那麼這些服務所擁有的執行緒也需要結束。由於無法通過搶佔式的方法來停止執行緒,因此它們需要自行結束。

正確的封裝原則是:除非擁有某個執行緒,否則不能對該執行緒進行操控,例如,中斷執行緒或者修改執行緒優先順序等。

對於持有執行緒的服務,主要服務的存在時間大於建立執行緒的方法的存在時間,那麼就應該提供生命週期方法。

示例:日誌服務

public class LogWriter{
    private final BlockingQueue<String> queue;
    private final LoggerThread thread;
    
    public LogWriter(Writer writer){
        this.queue = new BlokingQueue<>();
        this.logger = new LoggerThread(write);
    }
    
    public void start(){
        logget.start();
    }
    
    public void log(String msg) throws InterruptedException{
        queue.put(msg);
    }
    
    private class LoggerThread extends Thread{
        private final PrintWriter writer;;
        
        public void run(){
            try{
                while(true){
                    writer.println(queue.take());
                }
            } catch(InterruptedException ignored){
                
            } finally {
                writer.close();
            }
            
        }
    }
}

要停止日誌執行緒是很容易的,因為它會反覆呼叫take,而take能響應中斷。如果日誌執行緒修改為捕獲到InterruptedException時退出,那麼只需要中斷日誌執行緒就能停止服務。

然而,如果只是使日誌執行緒退出,那麼還不是一種完備的關閉機制。這種直接關閉的做法會丟失那些正在等待被寫入到日誌的資訊,不僅如此,其他執行緒將在呼叫log時被阻塞,因為日誌訊息佇列是滿的,因此這些執行緒將無法解除阻塞狀態。

另一種關閉LogWriter的方法是:設定某個“已請求關閉”標誌,避免進一步提交日誌訊息。

public void log(String msg) throws InterruptedException{
    if(!shutdownRequested){
        queue.put(msg);
    }else {
        throw new IllegalStateException("logger is shut down");
    }
}

為LogWriter提供可靠關閉操作的方法是解決競態條件問題,因而要使日誌訊息的提交操作成為原子操作。

public class LogService{
    private final BlockingQueue<String> queue;
    private final LoggerThread loggerThread;
    private final PrintWriter writer;
    private boolean isShutdown;
    private int reservations;
    
    public void start(){
        loggerThread.start();
    }
    
    public void stop(){
        synchronized(this){
            isShutdown = true;
        }
        loggerThread.interrupt();
    }
    
    public void log(String msg) throws InterruptedException{
        synchronized(this){
            if(isShutdown){
                throw new IllegalStateException(...);
            }
            ++reservations;
        }
        queue.put(msg);
    }
    
    
    private class LoggerThread extends Thread{
        public void run(){
            try{
                while(true){
                    try{
                        synchronized(LogService.this){
                            if(isShutdown && reservations == 0){
                                break;
                            }
                            String msg = queue.take();
                            synchronized(LogService.this){
                                --reservations;
                            }
                            writer.println(msg);                        
                        } catch(InterruptedException e){
                            /* retry */
                        }
                    } 

                }
            } finally {
                writer.close();
            }
        }
    }
}

關閉ExecutorService

在複雜程式中,通常會將ExecutorService封裝在某個更高階別的服務中,並且該服務能提供其自己的生命週期方法。

public class LogService{
    private final ExecutorService exec = newSingleThreadExecutor();
    
    public void start();
    
    public void stop() throws InterruptedException {
        try{
            exec.shutdown();
            exec.awaitTermination(TIMEOUT,UNIT);
        } finally {
            writer.close();
        }
    }
    
    public void log(String msg) {
        try{
            exec.execute(new WriteTask(msg));
        } catch (RejectedExecutionException ignored) {
            
        }
    }
}

毒丸物件

另一種關閉生產者-消費者服務的方式就是使用毒丸物件。毒丸是指一個放在佇列上的物件,其含義是:當得到足夠物件時,立即停止。在FIFO佇列中,毒丸物件確保消費者在關閉之前首先完成佇列的所有工作,在提交毒丸物件之前提交的工作都會被處理,而生產者在提交了毒丸物件後,將不會提交任何工作。

public class IndexingService{
    private static final File POISON = new File("");
    private final IndexerThread consumer = new IndexerThread();
    private final CrawlerThread producer = new CrawlerThread();
    private final BlockingQueue<File> queue;
    private final File root;
    
    public void start(){
        producer.start();
        consumer.start();
    }
    
    public void stop(){
        producer.interrupt();
    }
    
    public void awaitTermination() throws InterruptedException{
        consumer.join();
    }
    
    class CrawlerThread extends Thread{
        public void run(){
            try{
                crawl(root);
            } catch(InterruptedException e){
                
            } finally {
                while(true) {
                    try{
                        queue.put(POISON);
                        break;
                    } catch(InterruptedException e1){
                        /*重新嘗試*/
                    }
                }
            }
        }
        
        private void crawl(File root) throws InterruptedException{
            ...
        }
    }
    
    class IndexerThread extends Thread{
        public void run(){
            try{
                while(true){
                    File file = queue.take();
                    if(file == POISON){
                        break;
                    } else {
                        indexFile(file);
                    }
                }
            } catch(InterruptedException consumed){
                
            }
        }
    }
}

示例:只執行一次的服務

如果某個方法需要處理一批任務,並且當所有任務都處理完成後才返回,那麼可以通過一個私有的Executor來簡化服務的生命週期管理,其中該Executor的生命週期是由這個方法來控制的(這種情況下,invokeAll和invokeAny等方法通常會起較大的作用)

boolean checkMail(Set<String> hosts,long timeout,TimeUnit unit) throws InterruptedException{
    ExecutorService exec = Executors.newCachedThreadPool();
    final AtomicBoolean hasNewMail = new AtomicBoolean(false);
    
    try{
        for(final String host : hosts){
            exec.execute(new Runnable(){
                public void run(){
                    if(checkMail(host)){
                        hasNewMail.set(true);
                    }
                }
            });
        }
    } finally {
        exec.shutdown();
        exec.awaitTermination(timeout,unit);
    }
    return hasNewMail.get();
}

checkMail能在多臺主機上並行的檢查新郵件。它建立了一個私有的Executor,並向每臺主機提交一個任務。然後,當所有郵件檢查任務都執行完成後,關閉Executor並等待結束。之所以採用AtomicBoolean是因為能從內部的Runnable中訪問hasNewMail標識。

shutdownNow的侷限性

當通過shutdownNow來強行關閉ExecutorService時,它會嘗試取消正在執行的任務並返回所有已提交但尚未開始的任務,從而將這些任務寫入日誌或者儲存起來以便之後進行處理。

我們無法通過常規方法來找出哪些任務已經開始但尚未結束。這意味著我們無法在關閉過程中知道正在執行的任務的狀態,除非任務本身會執行某種檢查。

TrackingExecutor中給出瞭如何在關閉過程中判斷正在執行的任務。

public class TrackingExecutor extends AbstractExecutorService{
    private final ExecutorService exec;
    private final Set<Runnable> tasksCancelledAtShutdown = Collections.synchronizedSet(new HashSet<Runnable>());
    
    public List<Runnable> getCancelledTasks(){
        if(!exec.isTerminated){
            throw new IllegalStateException(..);
        }
        return new ArrayList<Runnable>(tasksCancelledAtShutdown);
    }
    
    public void execute(final Runnable runnable){
        exec.execute(new Runnable(){
            public void run(){
                try{
                    runnable.run();
                } finally {
                    if(isShutdown() && Thread.currentThread().isInterrupted()){
                        tasksCancelledAtShutdown.add(runnable);
                    }
                }
            }
        });
    }
    
    //將ExecutorService的其他方法委託給exec
}

使用TrackingExecutorService來儲存未完成的任務以備後續執行

public abstract class WebCrawler{
    private volatile TrackingExecutor exec;
    private final Set<URL> urlsToCrawl = new HashSet<URL>();
    
    public synchronized void start(){
        exec = new TrackingExecutor(Executors.newCachedThreadPool());
        for(URL url : urlsToCrawl) {
            submitCrawlTask(url);
        }
        urlsToCrawl.clear();
    }
    
    public synchronzied void stop() throws InterruptedException {
        try{
            saveUncrawled(exec.shutdownNow());
            if(exec.awaitTermination(TIMEOUT,UNIT)){
                saveUncrawled(exec.getCancelledTasks());
            }
        } finally {
            exec = null;
        }
    }
    
    protected abstract List<URL> processPage(URL url);
    
    private void saveUncrawled(List<Runnable uncrawled){
        for(Runnable task : uncrawled){
            urlsToCrawl.add(((CrawlTask) task).getPage());
        }
    }
    
    private void submitCrawlTask(URL url){
        exec.execute(new CrawlTask(u));
    }
    
    private class CrawlTask implements Runnable{
        private final URL url;
        
        public void run(){
            for(URL link : processPage(url)){
                if(Thread.currentThread().isInterrupted()){
                    return;
                }
                submitCrawlTask(link);
            }
        }
        
        public URL getPage(){
            return url;
        }
    }
    
}

在TrackingExecutor中存在一個不可避免的競態條件,從而產生“誤報”問題:一些被認為已經取消的任務實際上已經執行完成。原因在於,在任務執行最後一條指令以及執行緒池將任務記錄為結束的兩個時刻之間,執行緒池可能被關閉。如果任務是冪等的(Idempotent,即將任務執行兩次和執行一次會得到相同的結果),那麼不會存在問題。

處理非正常的執行緒終止

當併發程式中的某個執行緒發生故障使控制檯中可能會輸出棧追蹤資訊,但是沒有人會觀察控制檯。此外,當執行緒發生故障時,應用程式可能看起來仍然在工作,所以這個失敗很可能被忽略。幸運的是,我們有可以監測並防止程式中“遺漏”執行緒的方法。

導致執行緒死亡的最主要原因就是RuntimeException。這是unchecked異常,程式預設會在控制檯輸出棧追蹤資訊,並終止執行緒。

典型的執行緒池工作者執行緒結構

public void run(){
    Throwable thrown = null;
    try{
        while(!isInterrupted()){
            runTask(getTaskFromWorkueue());
        }
    } catch (Throwable e) {
        thrown = e;
    } finally {
        threadExited(this,throw);
    }
}

Thread api中同樣提供了UncaughtExceptionHandler,它能檢測出某個執行緒由於未捕獲的異常而終結的情況。

當一個執行緒由於未捕獲異常而退出時,JVM會把這個事件報告給應用程式提供的UncaughtExceptionHandler異常處理器。如果沒有提供任何異常處理器,那麼預設的行為是將棧追蹤資訊輸出到System.err.

public interface UncaughtExceptionHandler {
    void uncaughtException(Thread t,Throwable e);
}

異常處理器如何處理未捕獲異常,取決於對服務質量的需求。最常見的響應方式是將一個錯誤資訊以及相應的棧追蹤資訊寫入應用程式日誌中。

public class USHLogger implements Thread.UncaughtExceptionHandler {
    public void uncaughtException(Thread t,Throwable e){
        Logger logger = logger.getAnonymousLogger();
        logger.log(Level.SEVERE,"Thread terminated with exception :" + t.getName()),e);
    }
}

在執行時間較長的應用程式中,通常會為所有執行緒的未捕獲異常指定同一個異常處理器,並且該處理器至少會將異常資訊記錄到日誌中。

要為執行緒池中的所有執行緒設定一個UncaughtExceptionHandler,需要為ThreadPoolExecutor的建構函式提供一個ThreadFactory。標準執行緒池允許當發生未捕獲異常時結束執行緒,但由於使用了一個try-finally程式碼來接收通知,因此當執行緒結束時,將有新的執行緒來代替它。如果沒有提供捕獲異常處理器或者其他的故障通知機制,那麼任務會悄悄失敗,從而導致極大的混亂。如果你希望在任務由於傳送異常而失敗時獲得通知並且執行一些特定於任務的恢復操作,那麼可以將任務封裝在能捕獲異常的Runnable或Callable中,或者改寫ThreadPoolExecutor的afterExecute方法。

令人困惑的是,只有通過execute提交的任務,才能將它丟擲的異常交給未捕獲異常處理器,而通過submit提交的任務,會被封裝成ExecutionException丟擲。

JVM關閉

JVM既可以正常關閉也可以強行關閉。正常關閉的觸發方式有多種,包括:當最後一個“非守護“執行緒結束時,或者呼叫System.exit時,或者通過其他特定平臺的方法關閉時(例如傳送了SIGINT訊號或鍵入crtl + C)。但也可以呼叫Runtime.halt或者在作業系統中殺死JVM程式來強行關閉JVM.

關閉鉤子

在正常關閉中,JVM首先呼叫所有已註冊的關閉鉤子(Shutdown hook)。關閉鉤子是指通過Runtime.addShutdownHook註冊的但尚未開始的執行緒。JVM並不能保證關閉鉤子的呼叫順序。在關閉應用程式執行緒中,如果執行緒仍然在執行,那麼這些執行緒接下來和關閉程式併發執行。如果runFinalizerOnExit為true。那麼JVM將執行終結器,然後再停止。JVM並不會停止或中斷任何在關閉時仍然執行的應用程式執行緒。當JVM最終結束時,這些執行緒將被強行結束。如果關閉鉤子或終結器沒有執行完成,那麼正常關閉程式“掛起”並且JVM必須被強行關閉。當強行關閉時,只是關閉JVM,而不會執行關閉鉤子。

關閉鉤子應該是執行緒安全的:它們在訪問共享資料時必須使用同步機制,並且小心的避免死鎖,這和其他併發程式碼的要求相同。而且,關閉鉤子不應該對應用程式的狀態或者JVM的關閉原因作出任何假設。最後,關閉鉤子應該儘快退出,因為它們會延遲JVM的結束時間,而使用者可能希望JVM儘快終止。

關閉鉤子可以用於實現服務或應用程式的清理工作,例如清理臨時檔案。

由於關閉鉤子將併發執行,因此在關閉日誌檔案時可能導致其他需要日誌服務的關閉鉤子產生問題。實現這種功能的一種方式是對所有服務使用同一個關閉鉤子,並且在關閉鉤子中執行一系列的關閉操作。

public void start(){
    Runtime.getRuntime().addShutdownHook(new Thread(){
        public void run(){
            try{
                LogService.this.stop();
            } catch(InterruptedException ignored){
                
            }
        }
    }
}

守護執行緒

執行緒分為兩種:普通執行緒和守護執行緒。在JVM啟動時建立的所有執行緒中,除了主執行緒,其他都是守護執行緒(例如GC等)。

普通執行緒和守護執行緒的差異僅僅在於當執行緒退出時發生的操作。當一個執行緒退出時,JVM會檢查其他正在執行的執行緒,如果這些執行緒都是守護執行緒,JVM會正常退出操作,當JVM停止時,所有仍然存在的守護執行緒都將被拋棄——既不會執行finally程式碼塊,也不會執行回捲棧,而JVM只是直接退出。

此外,守護執行緒通常不能用來代替應用程式管理程式中各個服務的生命週期

終結器

當不再需要記憶體資源時,可以通過GC來回收他們,但對於其他一些資源,例如檔案控制程式碼或套接字控制程式碼,必須顯式的還給作業系統。為了實現這個功能,垃圾回收器對那些定義了finalize方法的物件會進行特殊處理:在回收器釋放它們後,呼叫它們的finalize方法,從而保證一些持久化資源被釋放。

由於終結器可以在某個由JVM管理的執行緒中執行因此終結器訪問的任何狀態都可能被多個執行緒訪問,這樣就必須對其訪問操作進行同步。在大多數情況下,通過finally程式碼塊和顯式的close方法,能夠比使用終結器更好的管理資源。唯一的例外是:當需要管理物件,並且該物件持有的資源是通過本地方法獲得的。

儘量避免使用終結器

相關文章