【LeetCode】1114. Print in Order(多線程:按序打印)

LeetCode新出了一類多線程的題目,主要考察的是對編程語言中多線程的用法以及多線程的算法,目前只有四題,這篇博客是對第一個1114這個多線程題目的分析。

題目

Suppose we have a class:

public class Foo {
  public void first() { print("first"); }
  public void second() { print("second"); }
  public void third() { print("third"); }
}

The same instance of Foo will be passed to three different threads. Thread A will call first(), thread B will call second(), and thread C will call third(). Design a mechanism and modify the program to ensure that second() is executed after first(), and third() is executed after second().

Example 1:

Input: [1,2,3]
Output: “firstsecondthird”
Explanation: There are three threads being fired asynchronously. The input [1,2,3] means thread A calls first(), thread B calls second(), and thread C calls third().“firstsecondthird” is the correct output.

Example 2:

Input: [1,3,2]
Output: “firstsecondthird”
Explanation: The input [1,3,2] means thread A calls first(), thread B calls third(), and thread C calls second(). “firstsecondthird” is the correct output.

Note:

We do not know how the threads will be scheduled in the operating system, even though the numbers in the input seems to imply the ordering. The input format you see is mainly to ensure our tests’ comprehensiveness.

題意

題目給出了三個方法方法:

  public void first() { print("first"); }
  public void second() { print("second"); }
  public void third() { print("third"); }

後臺的main方法將會以多線程的形式調用者三個方法,至於調用順序,既然是多線程,那麼調用順序和執行順序是沒必然關聯的,可能先創建的線程裏的代碼有更大概率先執行,但這不是絕對的。

題目給出的例子第一眼看過去似乎有點摸不着頭腦(本人覺得),不就是按順序打印三個單詞嘛,結果只有一個,爲什麼還要給不同的輸入?這其實就是告訴我們不管線程的調用順序如何都要使結果按序輸出。

代碼

以下將採用Java中不同方法對這題進行實現,來體會一下Java中各種不同的多線程實現機制

1. 不加鎖

看到題目之後,大家應該都會想到這一題貌似不用多線程的東西都可以解決,也就是使用一個標記變量來標記當前需要執行哪一個打印代碼。當前面的打印任務沒完成時,使用while循環來等待標記變成當前打印任務可執行的標記,噹噹前打印任務完成是將標記賦值爲下一個打印任務可行的標記。具體代碼如下:

package Concurrency.PrintInOrder;

class Foo {
	//因爲沒有加鎖,因此要加volatile關鍵字保證i的可見性
    private volatile int i; 

    public Foo() {
		i = 1;
    }

    public void first(Runnable printFirst) throws InterruptedException {
        while (i != 1);
        // printFirst.run() outputs "first". Do not change or remove this line.
        printFirst.run();
        i = 2;
    }

    public void second(Runnable printSecond) throws InterruptedException {
        while (i != 2);
        // printSecond.run() outputs "second". Do not change or remove this line.
        printSecond.run();
        i = 3;
    }

    public void third(Runnable printThird) throws InterruptedException {
        while (i != 3);
        // printThird.run() outputs "third". Do not change or remove this line.
        printThird.run();
    }
}

2. 使用synchronized對方法加鎖

回頭看一下上面不加鎖的代碼,可以想到不加鎖會存在while不斷循環提高內存消耗的問題(事實上就打印三個單詞,體現不出內存消耗差別),因此我們考慮到以加鎖和阻塞等待的方式改寫代碼。

同樣的,我們首先要使用一個標記變量來標記當前需要執行哪一個打印代碼,不同的是在標記不是當前打印任務可執行的標記時不是進行不斷循環,而是進行等待(wait()方法),等待標記發生變化,當標記仍不是當前任務可執行的標記時繼續等待,注意在每個方法上加上synchronized關鍵字:

class Foo {
    private int i;

    public Foo() {
        i = 1;
    }

    public synchronized void first(Runnable printFirst) throws InterruptedException {
    	// 其實i初始化爲1此處就不用寫這個while,但爲了每個方法看起更統一(優雅︿( ̄︶ ̄)︿),也寫上了,這不影響效率
        while (i != 1) {
            wait();
        }
        
        // printFirst.run() outputs "first". Do not change or remove this line.
        printFirst.run();
        
        // 將標記賦值爲2,讓打印second的代碼可以執行
        
        i = 2;
        //通知其他線程我打印完了,標記i有變化了
        notifyAll(); 
    }

    public synchronized void second(Runnable printSecond) throws InterruptedException {
        while (i != 2) {
            wait();
        }
        
        // printSecond.run() outputs "second". Do not change or remove this line.
        printSecond.run();
        i = 3;
        notifyAll();
    }

    public synchronized void third(Runnable printThird) throws InterruptedException {
        while (i != 3) {
            wait();
        }
        
        // printThird.run() outputs "third". Do not change or remove this line.
        printThird.run();
    }
}

上面的代碼似乎有些重複代碼,來重構一下:

class Foo {
    private int i;

    public Foo() {
        i = 1;
    }

    public synchronized void first(Runnable printFirst) throws InterruptedException {
        fun(1, 2, printFirst);
    }

    public synchronized void second(Runnable printSecond) throws InterruptedException {
        fun(2, 3, printSecond);
    }

    public synchronized void third(Runnable printThird) throws InterruptedException {
        fun(3, 0, printThird);
    }

    /**
     * 
     * @param expect 執行當前打印任務需要的標記值
     * @param next 下一個打印任務需要的標記值
     * @param print 打印任務的線程
     * @throws InterruptedException
     */
    public void fun(int expect, int next, Runnable print) throws InterruptedException {
        while (i != expect) {
            wait();
        }

        print.run();
        i = next;
        notifyAll();
    }
}

這樣子代碼看起來就舒服多了( ̄▽ ̄)~*

3. 使用具備CAS操作的原子類

CAS是一種樂觀鎖機制,其實是不加鎖但是達到了加鎖的效果、避免了阻塞,感興趣的可以看這裏Java CAS 原理分析,下面就是使用Java中具備CAS操作的AtomicInteger的代碼:

// 注意導入這個原子類
import java.util.concurrent.atomic.AtomicInteger;

class Foo {
    private AtomicInteger i;

    public Foo() {
        i = new AtomicInteger(1);
    }

    public void first(Runnable printFirst) throws InterruptedException {
    	// 如果i的值是1則將i的值設置爲2,這涉及到CAS操作的,不懂的可以看前面給的鏈接
        while (!i.compareAndSet(1,2));
        // printFirst.run() outputs "first". Do not change or remove this line.
        printFirst.run();
    }

    public void second(Runnable printSecond) throws InterruptedException {
        while (!i.compareAndSet(2,3));
        // printSecond.run() outputs "second". Do not change or remove this line.
        printSecond.run();
    }

    public void third(Runnable printThird) throws InterruptedException {
        // printThird.run() outputs "third". Do not change or remove this line.
        while (!i.compareAndSet(3,4));
        printThird.run();
    }
}

4. 使用重入鎖

Java中的重入鎖ReentrantLock是一種輕量級鎖,而synchronized是重量級鎖,輕量級鎖配合好多項成算法可以提高程序的執行效率,關於輕量級鎖可以看淺談偏向鎖、輕量級鎖、重量級鎖,其實以下這段和使用synchronized是一樣的只是,將鎖換成了ReentrantLock,把wait和notifyAll換成了Condition的await和signal,關於可以看Java併發學習之ReentrantLock的工作原理及使用姿勢

這裏可能有人會注意到,我使用了兩個Condition,完全可以使用一個Condition,然後採用synchronized實現中一樣的邏輯來寫呀?關於這個我的想法是:在synchronized實現的代碼中,使用的是notifyAll,是通知所有在等待的線程,而我這裏使用兩個Condition的話,下一個要執行的是哪個線程我就去通知哪個線程,這樣其他線程中的while就不會在此進行循環了,雖然在這個程序中體現不出任何優勢,但如果深入思考——如果線程很多且while中的wait之前要執行耗時操作(比如大量打印日誌),此時每個線程分配一個單獨的Condition就有優勢了。這是我的想法,不知道在真實場景中對不對,如果大佬認爲思路有問題,請評論告知呀。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

class Foo {
    private int i = 1;
    private ReentrantLock lock;
    private Condition secondCondition, thirdCondition;

    public Foo() {
        lock = new ReentrantLock();
        secondCondition = lock.newCondition();
        thirdCondition = lock.newCondition();
    }

    public void first(Runnable printFirst) throws InterruptedException {
        lock.lock();
        // printFirst.run() outputs "first". Do not change or remove this line.
        printFirst.run();
        i = 2;
        secondCondition.signal();
        lock.unlock();
    }

    public void second(Runnable printSecond) throws InterruptedException {
        lock.lock();
        while (i != 2) {
            secondCondition.await();
        }
        // printSecond.run() outputs "second". Do not change or remove this line.
        printSecond.run();
        i = 3;
        thirdCondition.signal();
        lock.unlock();
    }

    public void third(Runnable printThird) throws InterruptedException {
        lock.lock();
        while (i != 3) {
            thirdCondition.await();
        }
        // printThird.run() outputs "third". Do not change or remove this line.
        printThird.run();
        lock.unlock();
    }
}

上面的代碼好像也有些重複代碼,再來重構一下:

package Concurrency.PrintInOrder;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

class Foo {
    private int i = 1;
    private ReentrantLock lock;
    private Condition secondCondition, thirdCondition;

    public Foo() {
        lock = new ReentrantLock();
        secondCondition = lock.newCondition();
        thirdCondition = lock.newCondition();
    }

    public void first(Runnable printFirst) throws InterruptedException {
        fun(1, 2, null, secondCondition, printFirst);
    }

    public void second(Runnable printSecond) throws InterruptedException {
        fun(2, 3, secondCondition, thirdCondition, printSecond);
    }

    public void third(Runnable printThird) throws InterruptedException {
        fun(3, 0, thirdCondition, null, printThird);
    }
    
    /**
     * 這重構的好像有點臭ε(┬┬﹏┬┬)3,不過看起來還是比上面舒服
     * @param expect 執行當前打印任務需要的標記值
     * @param next 下一個打印任務需要的標記值
     * @param currentCondition 當前任務執行的Condition
     * @param nextCondition 下一個任務執行的Condition
     * @param print 打印任務的線程
     * @throws InterruptedException
     */
    public void fun(int expect, int next, Condition currentCondition, Condition nextCondition, Runnable print) throws InterruptedException {
        lock.lock();

        while (currentCondition != null && i != expect) {
            currentCondition.await();
        }

        print.run();
        i = next;

        if (nextCondition != null) {
            nextCondition.signal();
        }

        lock.unlock();
    }
}

測試

由於題目沒有給出測試方法,我寫了一個main方法作爲測試,至於題目中不同的輸入交換一下不同線程的順序即可,其實沒什麼區別:

public static void main(String[] args) {
        Foo foo = new Foo();
        
        Runnable printFirst = () -> {
            System.out.println("first");
        };

        Runnable printSecond = () -> {
            System.out.println("first");
        };

        Runnable printThird = () -> {
            System.out.println("third");
        };

        try {
            foo.first(printFirst);
            foo.second(printSecond);
            foo.first(printThird);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

總結

這道題其實使用上面四種實現方式執行時間和內存消耗上都幾乎沒什麼差別,主要是因爲這裏只打印三個單詞,完全體現不出差別,在LeetCode上執行代碼的時間和空間完全取決於服務器的狀態,其實也就是靠運氣了┗( ▔, ▔ )┛,如果以上內容有任何不合理的地方歡迎大佬下方留言告知。

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