單例模式
這是一種常見的“設計模式”。
“設計模式”類似於“棋譜”。
場景:代碼中的有些概念不應該存在多個實例,此時應該使用單例模式來解決
兩種典型的方式實現單例模式:
1、餓漢模式:“餓”代表只要類被加載,就會立刻實例化 Singleton 實例,後續無論怎麼操作,只要永遠不使用 getlnstance,就不會出現其他的實例。
2、懶漢模式
類加載的時候,沒有立刻實例化,第一次調用 getInstance 的時候纔會真正實例化,如果要是代碼一整場都沒有調用getInstance 此時實例化的過程也就被省略了
那麼單例模式和線程有什麼關係呢?
剛纔兩種單例模式的實現方式中,餓漢是線程安全的,懶漢是線程不安全的。
原因:
首先回顧一下導致線程不安全的原因:1.線程的調度搶佔式執行;2.修改操作不是原子的;3.多線程同時修改同一個變量;4.內存可見性;5.指令重排序。對於餓漢來說,多線程同時調用 getInstance,由於 getInstance 裏只做了一件事:讀取 instance 實例的地址,這就代表着多個線程在同時讀取同一個變量,並不是修改,所以餓漢是線程安全的。
對於懶漢模式來說,多線程同時調用 getInstance ,getInstance中做了四件事:1.讀取 instance 的內容;2.判斷 instance 是否爲 null;3.如果 instance 爲 null,就 new 實例;4.返回實例的地址。在第二步操作中 new 實例會修改 instance 的值。所以是線程不安全的。
用一個時間軸來展示懶漢模式:
如何改進懶漢模式,讓代碼變成線程安全的?
第一種優化方式:加鎖
下面展示一個錯誤的修改方式:
這樣寫,此時讀取判斷,操作和 new 修改操作讓不是原子的,下面的操作爲正確的解決辦法
這兩種寫法都是正確的,認爲上面的寫法鎖的粒度更小,下面的鎖的粒度更大,(鎖中包含的代碼越多就認爲“粒度”越大),一般代碼的粒度越小越好。
另一種優化方式:在鎖上方再加一個 if 後這樣可以提高效率:
public static Singleton getInstance(){
if (instance == null) {
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton();
}
}
}
return instance;
}
單例模式爲了保證線程安全,涉及到三個要點:
- 加鎖保證線程安全
- 雙重 if 保證效率
- volatile 避免內存可見性引來的問題
阻塞隊列
是併發編程中的一個重要基礎組件,幫助我們實現“生產者-消費者模型”(這是一種典型的處理併發編程的模式)
“生產者-消費者模型”:
舉一個生活中的例子,就是如果甲、乙、丙三個人需要一個桌子
他們需要的操作步驟就是:
1.用斧子砍樹 2.把木頭通過小車車運輸 3.拼接成桌子
那麼就會有兩種情況:第一種情況是三個人每個人都操作一遍兩個步驟;第二種情況就是甲砍樹,乙運輸,丙個人拼接桌子
第一種情況對於斧子(鎖)的需求太高,而第二種情況就比較常見,其中第一個人就是生產者,另外兩個人就是消費者
還有一個問題就是甲砍樹太快,小車車放不下;或者乙運輸的太快,丙不能很快拼接完成。
阻塞隊列的特點也是如此,他是一個先進先出的隊列:入隊列的時候如果發現隊列滿了就會阻塞,直到有其他線程調用出隊列操作讓隊列中有空位之後,才能繼續入隊列;如果出隊列操作太快,隊列空了額,繼續出隊列,也會阻塞,一直阻塞到有其他線程生產了元素,才能繼續出隊列
隊列的基本操作:
- 入隊列
- 出隊列
- 取隊首元素
阻塞隊裏只提供前兩個操作,不支持取隊首元素
//阻塞版本的入隊列
public void put(int value) throws InterruptedException {
synchronized (this) {
if (size == array.length){
wait();
}
array[tail] = value;
tail++;
if (tail == array.length){
tail = 0;
}
size++;
notify();
}
}
//阻塞版本的出隊列
public int take() throws InterruptedException {
int ret = -1;
synchronized (this){
if (size == 0){
wait();
}
ret = array[head];
head = 0;
if (head == array.length) {
head = 0;
}
size--;
notify();
}
return ret;
}
}
}
體會上面的兩個wait 操作,一個在隊列滿的時候阻塞,一個在隊列空的時候阻塞,兩個操作永遠不會衝突
假設兩個線程入隊列,一個線程入隊列,一個線程出隊列,此時如果隊列已經滿了,兩個入隊列線程就會線程就阻塞了,此時如果出隊列操作
如果多個線程 wait notify 的時候喚醒哪個線程由操作系統調度器說了算(程序員的角度理解就是隨機的)
如果沒有 wait 執行了 notify 沒有影響,有線程在 wait ,notify 就就喚醒一個線程,沒有線程 wait 不會有任何負面影響
public static void main(String[] args) {
BlockingQueue blockingQueue = new BlockingQueue();
//第一次讓消費者消費的快一些,生產者慢一些
//此時就會消費者等待
//第二次讓消費者生產的快一些,消費者慢一些
//此時就會預期看到,生產者線程剛開始的時候會快速插入元素,直到隊列滿的時候就會阻塞
//此時就要消費了以後才能生產
Thread producer = new Thread(){
@Override
public void run(){
for (int i = 0; i < 10000; i++) {
try {
blockingQueue.put(i);
System.out.println("生產元素:" + i);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
producer.start();
Thread consumer = new Thread(){
@Override
public void run(){
while (true){
try {
int ret = blockingQueue.take();
System.out.println("消費元素:" + ret);
//
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
consumer.start();
}
}
運行效果:即使消費的比生產的快,但是還是要等生產完成後才能消費
public static void main(String[] args) {
BlockingQueue blockingQueue = new BlockingQueue();
//第一次讓消費者消費的快一些,生產者慢一些
//此時就會消費者等待
//第二次讓消費者生產的快一些,消費者慢一些
//此時就會預期看到,生產者線程剛開始的時候會快速插入元素,直到隊列滿的時候就會阻塞
//此時就要消費了以後才能生產
Thread producer = new Thread(){
@Override
public void run(){
for (int i = 0; i < 10000; i++) {
try {
blockingQueue.put(i);
System.out.println("生產元素:" + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
producer.start();
Thread consumer = new Thread(){
@Override
public void run(){
while (true){
try {
int ret = blockingQueue.take();
System.out.println("消費元素:" + ret);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
consumer.start();
}
}
第二次運行效果:剛開始生產者比較快就快速生產,直到隊列滿了,就阻塞等待消費者消耗了以後才繼續生產
當有兩個消費者線程的時候
當兩個消費者都觸發 wait 操作後,接下來當我們調用 notifyAll 的時候,就把上面兩個線程都喚醒了,於是兩個線程就都去重新獲取鎖:
消費者1 ,先獲取到鎖,於是就執行下面出隊列操作(執行完畢釋放鎖)
消費者2,後獲取到鎖,於是也會執行下面的出隊列操作,但是注意:剛纔生產者生產的一個元素,已經被消費者1 線程給取走了,當前實際是一個空隊列,如果強行往下執行取隊裏取素操作,就會出現邏輯錯誤。
定時器
相當於一個鬧鐘,進行任務的管理。
定時器是多線程編程中的一個重要/常用組件,應用場景非常廣泛,網絡編程中特別常見。
定時器的構成:
- 使用一個類來描述“一段邏輯”(一個要執行的任務),同時也要記錄這個任務在什麼時間點執行
- 使用一個阻塞優先隊列來組織若干個 Task。(使用優先隊列是爲了保證隊首元素就是要被最早執行的任務)【阻塞隊列既支持阻塞的特性,又支持優先級的“先進先出”,本質上是一個“堆”】
- 需要一個掃描線程,不停的掃描,判定隊首是否時間到。(掃描線程要循環的檢測,隊首元素是否需要執行,如果需要執行的話,就執行這個任務。)
- 實現一個方法 schedule,給定時器內部安排一個任務。
- 爲了避免忙等,還需要引入一個額外的對象,讓掃描線程藉助這個對象進行 wait 。(使用帶超時時間版本的 wait)
隨意一個對象都可以放入優先隊列中麼?
答:優先隊裏而需要知道對象之間的大小關係,才能把優先級排出來(才能保證隊首元素是優先級最高的)
優先隊列中的元素必須是可比較的
比較規則的指定主要是兩種方式:1、讓 Task 實現 Comparable 接口 2、讓優先隊列構造的時候,傳入一個比較器對象(Comparator)
標準庫中其實已經提供了阻塞隊列,定時器等基本組件,實際工作中,可以直接運用,下面的代碼是爲了理解原理,也是爲了加深對多線程的掌握。
import java.sql.Time;
import java.util.concurrent.PriorityBlockingQueue;
public class ThreadDemo1 {
//優先隊列中的元素必須是可比較的
//比較規則的指定主要是兩種方式
static class Task implements Comparable<Task> {
//Runnable 中有一個 run 方法,就可以藉助這個 run 方法來描述要執行的具體任務是什麼
private Runnable command;
//time 表示什麼時候來執行 command,是一個絕對時間(ms級別的時間戳)
private long time;
//構造方法的 after 參數表示:after 秒後執行(是一個相對時間)
//這個相對時間的參數是爲了而用起來方便
public Task(Runnable command, long after) {
this.command = command;
this.time = System.currentTimeMillis() + after;
}
//執行具體的邏輯
public void run(){
command.run();
}
@Override
public int compareTo(Task o) {
return (int) (this.time - o.time);
}
}
static class Worker extends Thread{
private PriorityBlockingQueue<Task> queue = null;
public Worker(PriorityBlockingQueue<Task> queue) {
this.queue = queue;
}
@Override
public void run() {
//實現具體的線程執行內容
while (true){
try {
//1.取出隊首元素,檢查時間是否到了
Task task = queue.take();
//2.檢查當前任務時間是否到了
long curTime = System.currentTimeMillis();
if (task.time > curTime){
//時間還沒到,就把任務再放回隊列中
queue.put(task);
}else {
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}
static class Timer{
//1.用一個 Task 類來描述任務
//2.用一個阻塞隊隊列來組織若昂的任務,隊首元素就是時間最早的任務
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
//3.用一個線程循環掃描擋牆的阻塞隊列隊首元素,如果時間到,就執行任務
public Timer(){
//創建線程
Worker worker = new Worker(queue);
worker.start();
}
//4.還需要提供一個方法,讓調用者能把任務安排進來
public void schedule(Runnable command,long after){ //安排任務
Task task = new Task(command,after);
queue.put(task);
}
}
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hehe");
}
},5000);
}
}
我設置的任務爲運行5s 後輸出一個“hehe”
爲了更直觀的看到效果,可以把主函數的內容改成每隔 2s 執行一次輸出
運行10s 後的結果
忙等
舉個例子:我們定一個上課的鬧鐘時間爲 9:00,小明現在看了一下時間爲 8:00,又過了一會兒又看了一下時間8:01,又過了一會兒又看了一下時間8:02,剩下的時間還有將近一個小時,還可以做的事情有很多,但是小明一直在看時間,等待上課,這種頻繁的盯着表的行爲就叫作忙等。
我們的線程就可能會出現這種問題,掃描線程極快的運行 while 循環,有可能會大量的資源浪費 CPU 資源進行比較時間和入隊列出隊列操作。爲了解決這個問題,我們就要藉助 wait / notify 來解決。有下面幾種情況
- wait() 死等,一直等到 notify 的通知過來
- wait(time),等待是有上限的,如果有 notify 就被提前喚醒,如果沒有 notify,時間到了也一樣可以被喚醒。
代碼阻塞在 wait 處,避免了頻繁佔用 CPU
解決忙等問題部分的代碼:
static class Worker extends Thread{
private PriorityBlockingQueue<Task> queue = null;
private Object mailBox = null;
public Worker(PriorityBlockingQueue<Task> queue,Object mailBox) {
this.queue = queue;
this.mailBox = mailBox;
}
@Override
public void run() {
//實現具體的線程執行內容
while (true){
try {
//1.取出隊首元素,檢查時間是否到了
Task task = queue.take();
//2.檢查當前任務時間是否到了
long curTime = System.currentTimeMillis();
if (task.time > curTime){
//時間還沒到,就把任務再放回隊列中
queue.put(task);
synchronized (mailBox){
mailBox.wait(task.time - curTime);
}
}else {
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}
static class Timer{
//爲了避免忙等,需要使用 wait 方法
//使用一個單獨的對象來輔助進行 wait
private Object mailBox = new Object();
//1.用一個 Task 類來描述任務
//2.用一個阻塞隊隊列來組織任務,隊首元素就是時間最早的任務
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
//3.用一個線程循環掃描擋牆的阻塞隊列隊首元素,如果時間到,就執行任務
public Timer(){
//創建線程
Worker worker = new Worker(queue,mailBox);
worker.start();
}
//4.還需要提供一個方法,讓調用者能把任務安排進來
public void schedule(Runnable command,long after){ //安排任務
Task task = new Task(command,after);
queue.put(task);
synchronized (mailBox){
mailBox.notify();
}
}
}
在掃描線程內部加上 wait
在安排任務方法內部加上 notify
線程池