前言:
在Java中,線程部分是一個重點,本篇文章說的JUC也是關于線程的。JUC就是java.util .concurrent工具包的簡稱。這是一個處理線程的工具包,JDK 1.5開始出現的。下面一起來看看它怎么使用。
歡迎大家關注我的公眾號 javawebkf,目前正在慢慢地將簡書文章搬到公眾號,以后簡書和公眾號文章將同步更新,且簡書上的付費文章在公眾號上將免費。
一、volatile關鍵字與內存可見性
1、內存可見性:
先來看看下面的一段代碼:
public class TestVolatile {
public static void main(String[] args){ //這個線程是用來讀取flag的值的
ThreadDemo threadDemo = new ThreadDemo();
Thread thread = new Thread(threadDemo);
thread.start();
while (true){
if (threadDemo.isFlag()){
System.out.println("主線程讀取到的flag = " + threadDemo.isFlag());
break;
}
}
}
}
@Data
class ThreadDemo implements Runnable{ //這個線程是用來修改flag的值的
public boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("ThreadDemo線程修改后的flag = " + isFlag());
}
}
這段代碼很簡單,就是一個ThreadDemo類繼承Runnable創建一個線程。它有一個成員變量flag為false,然后重寫run方法,在run方法里面將flag改為true,同時還有一條輸出語句。然后就是main方法主線程去讀取flag。如果flag為true,就會break掉while循環,否則就是死循環。按道理,下面那個線程將flag改為true了,主線程讀取到的應該也是true,循環應該會結束。看看運行結果:
從圖中可以看到,該程序并沒有結束,也就是死循環。說明主線程讀取到的flag還是false,可是另一個線程明明將flag改為true了,而且打印出來了,這是什么原因呢?這就是內存可見性問題。
- 內存可見性問題:當多個線程操作共享數據時,彼此不可見。
看下圖理解上述代碼:
要解決這個問題,可以加鎖。如下:
while (true){
synchronized (threadDemo){
if (threadDemo.isFlag()){
System.out.println("主線程讀取到的flag = " + threadDemo.isFlag());
break;
}
}
}
加了鎖,就可以讓while循環每次都從主存中去讀取數據,這樣就能讀取到true了。但是一加鎖,每次只能有一個線程訪問,當一個線程持有鎖時,其他的就會阻塞,效率就非常低了。不想加鎖,又要解決內存可見性問題,那么就可以使用volatile關鍵字。
2、volatile關鍵字:
- 用法:
volatile關鍵字:當多個線程操作共享數據時,可以保證內存中的數據可見。用這個關鍵字修飾共享數據,就會及時的把線程緩存中的數據刷新到主存中去,也可以理解為,就是直接操作主存中的數據。所以在不使用鎖的情況下,可以使用volatile。如下:
public volatile boolean flag = false;
這樣就可以解決內存可見性問題了。
- volatile和synchronized的區別:
volatile不具備互斥性(當一個線程持有鎖時,其他線程進不來,這就是互斥性)。
volatile不具備原子性。
二、原子性
1、理解原子性:
上面說到volatile不具備原子性,那么原子性到底是什么呢?先看如下代碼:
public class TestIcon {
public static void main(String[] args){
AtomicDemo atomicDemo = new AtomicDemo();
for (int x = 0;x < 10; x++){
new Thread(atomicDemo).start();
}
}
}
class AtomicDemo implements Runnable{
private int i = 0;
public int getI(){
return i++;
}
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getI());
}
}
這段代碼就是在run方法里面讓i++,然后啟動十個線程去訪問。看看結果:
可以發現,出現了重復數據。明顯產生了多線程安全問題,或者說原子性問題。所謂原子性就是操作不可再細分,而i++操作分為讀改寫三步,如下:
int temp = i;
i = i+1;
i = temp;
所以i++明顯不是原子操作。上面10個線程進行i++時,內存圖解如下:
看到這里,好像和上面的內存可見性問題一樣。是不是加個volatile關鍵字就可以了呢?其實不是的,因為加了volatile,只是相當于所有線程都是在主存中操作數據而已,但是不具備互斥性。比如兩個線程同時讀取主存中的0,然后又同時自增,同時寫入主存,結果還是會出現重復數據。
2、原子變量:
JDK 1.5之后,Java提供了原子變量,在java.util.concurrent.atomic包下。原子變量具備如下特點:
- 有volatile保證內存可見性。
- 用CAS算法保證原子性。
3、CAS算法:
CAS算法是計算機硬件對并發操作共享數據的支持,CAS包含3個操作數:
- 內存值V
- 預估值A
- 更新值B
當且僅當V==A時,才會把B的值賦給V,即V = B,否則不做任何操作。就上面的i++問題,CAS算法是這樣處理的:首先V是主存中的值0,然后預估值A也是0,因為此時還沒有任何操作,這時V=B,所以進行自增,同時把主存中的值變為1。如果第二個線程讀取到主存中的還是0也沒關系,因為此時預估值已經變成1,V不等于A,所以不進行任何操作。
4、使用原子變量改進i++問題:
原子變量用法和包裝類差不多,如下:
//private int i = 0;
AtomicInteger i = new AtomicInteger();
public int getI(){
return i.getAndIncrement();
}
只改這兩處即可。
三、鎖分段機制
JDK 1.5之后,在java.util.concurrent包中提供了多種并發容器類來改進同步容器類的性能。其中最主要的就是ConcurrentHashMap。
1、ConcurrentHashMap:
ConcurrentHashMap就是一個線程安全的hash表。我們知道HashMap是線程不安全的,Hash Table加了鎖,是線程安全的,因此它效率低。HashTable加鎖就是將整個hash表鎖起來,當有多個線程訪問時,同一時間只能有一個線程訪問,并行變成串行,因此效率低。所以JDK1.5后提供了ConcurrentHashMap,它采用了鎖分段機制。
如上圖所示,ConcurrentHashMap默認分成了16個segment,每個Segment都對應一個Hash表,且都有獨立的鎖。所以這樣就可以每個線程訪問一個Segment,就可以并行訪問了,從而提高了效率。這就是鎖分段。但是,java 8 又更新了,不再采用鎖分段機制,也采用CAS算法了。
2、用法:
java.util.concurrent包還提供了設計用于多線程上下文中的 Collection 實現: ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、CopyOnWriteArrayList 和 CopyOnWriteArraySet。當期望許多線程訪問一個給 定 collection 時,ConcurrentHashMap 通常優于同步的 HashMap, ConcurrentSkipListMap 通常優于同步的 TreeMap。當期望的讀數和遍歷遠遠 大于列表的更新數時,CopyOnWriteArrayList 優于同步的 ArrayList。下面看看部分用法:
public class TestConcurrent {
public static void main(String[] args){
ThreadDemo2 threadDemo2 = new ThreadDemo2();
for (int i=0;i<10;i++){
new Thread(threadDemo2).start();
}
}
}
//10個線程同時訪問
class ThreadDemo2 implements Runnable{
private static List<String> list = Collections.synchronizedList(new ArrayList<>());//普通做法
static {
list.add("aaa");
list.add("bbb");
list.add("ccc");
}
@Override
public void run() {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());//讀
list.add("ddd");//寫
}
}
}
10個線程并發訪問這個集合,讀取集合數據的同時再往集合中添加數據。運行這段代碼會報錯,并發修改異常。
將創建集合方式改成:
private static CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
這樣就不會有并發修改異常了。因為這個是寫入并復制,每次生成新的,所以如果添加操作比較多的話,開銷非常大,適合迭代操作比較多的時候使用。
四、閉鎖
java.util.concurrent包中提供了多種并發容器類來改進同步容器的性能。ContDownLatch是一個同步輔助類,在完成某些運算時,只有其他所有線程的運算全部完成,當前運算才繼續執行,這就叫閉鎖。看下面代碼:
public class TestCountDownLatch {
public static void main(String[] args){
LatchDemo ld = new LatchDemo();
long start = System.currentTimeMillis();
for (int i = 0;i<10;i++){
new Thread(ld).start();
}
long end = System.currentTimeMillis();
System.out.println("耗費時間為:"+(end - start)+"秒");
}
}
class LatchDemo implements Runnable{
private CountDownLatch latch;
public LatchDemo(){
}
@Override
public void run() {
for (int i = 0;i<5000;i++){
if (i % 2 == 0){//50000以內的偶數
System.out.println(i);
}
}
}
}
這段代碼就是10個線程同時去輸出5000以內的偶數,然后在主線程那里計算執行時間。其實這是計算不了那10個線程的執行時間的,因為主線程與這10個線程也是同時執行的,可能那10個線程才執行到一半,主線程就已經輸出“耗費時間為x秒”這句話了。所有要想計算這10個線程執行的時間,就得讓主線程先等待,等10個分線程都執行完了才能執行主線程。這就要用到閉鎖。看如何使用:
public class TestCountDownLatch {
public static void main(String[] args) {
final CountDownLatch latch = new CountDownLatch(10);//有多少個線程這個參數就是幾
LatchDemo ld = new LatchDemo(latch);
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
new Thread(ld).start();
}
try {
latch.await();//這10個線程執行完之前先等待
} catch (InterruptedException e) {
}
long end = System.currentTimeMillis();
System.out.println("耗費時間為:" + (end - start));
}
}
class LatchDemo implements Runnable {
private CountDownLatch latch;
public LatchDemo(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
synchronized (this) {
try {
for (int i = 0; i < 50000; i++) {
if (i % 2 == 0) {//50000以內的偶數
System.out.println(i);
}
}
} finally {
latch.countDown();//每執行完一個就遞減一個
}
}
}
}
如上代碼,主要就是用latch.countDown()
和latch.await()
實現閉鎖,詳細請看上面注釋即可。
五、創建線程的方式 --- 實現Callable接口
直接看代碼:
public class TestCallable {
public static void main(String[] args){
CallableDemo callableDemo = new CallableDemo();
//執行callable方式,需要FutureTask實現類的支持,用來接收運算結果
FutureTask<Integer> result = new FutureTask<>(callableDemo);
new Thread(result).start();
//接收線程運算結果
try {
Integer sum = result.get();//當上面的線程執行完后,才會打印結果。跟閉鎖一樣。所有futureTask也可以用于閉鎖
System.out.println(sum);
} catch (Exception e) {
e.printStackTrace();
}
}
}
class CallableDemo implements Callable<Integer>{
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0;i<=100;i++){
sum += i;
}
return sum;
}
}
現在Callable接口和實現Runable接口的區別就是,Callable帶泛型,其call方法有返回值。使用的時候,需要用FutureTask來接收返回值。而且它也要等到線程執行完調用get方法才會執行,也可以用于閉鎖操作。
六、Lock同步鎖
在JDK1.5之前,解決多線程安全問題有兩種方式(sychronized隱式鎖):
- 同步代碼塊
- 同步方法
在JDK1.5之后,出現了更加靈活的方式(Lock顯式鎖):
- 同步鎖
Lock需要通過lock()方法上鎖,通過unlock()方法釋放鎖。為了保證鎖能釋放,所有unlock方法一般放在finally中去執行。
再來看一下賣票案例:
public class TestLock {
public static void main(String[] args) {
Ticket td = new Ticket();
new Thread(td, "窗口1").start();
new Thread(td, "窗口2").start();
new Thread(td, "窗口3").start();
}
}
class Ticket implements Runnable {
private int ticket = 100;
@Override
public void run() {
while (true) {
if (ticket > 0) {
try {
Thread.sleep(200);
} catch (Exception e) {
}
System.out.println(Thread.currentThread().getName() + "完成售票,余票為:" + (--ticket));
}
}
}
}
多個線程同時操作共享數據ticket,所以會出現線程安全問題。會出現同一張票賣了好幾次或者票數為負數的情況。以前用同步代碼塊和同步方法解決,現在看看用同步鎖怎么解決。
class Ticket implements Runnable {
private Lock lock = new ReentrantLock();//創建lock鎖
private int ticket = 100;
@Override
public void run() {
while (true) {
lock.lock();//上鎖
try {
if (ticket > 0) {
try {
Thread.sleep(200);
} catch (Exception e) {
}
System.out.println(Thread.currentThread().getName() + "完成售票,余票為:" + (--ticket));
}
}finally {
lock.unlock();//釋放鎖
}
}
}
}
直接創建lock對象,然后用lock()方法上鎖,最后用unlock()方法釋放鎖即可。
七、等待喚醒機制
1、虛假喚醒問題:
生產消費模式是等待喚醒機制的一個經典案例,看下面的代碼:
public class TestProductorAndconsumer {
public static void main(String[] args){
Clerk clerk = new Clerk();
Productor productor = new Productor(clerk);
Consumer consumer = new Consumer(clerk);
new Thread(productor,"生產者A").start();
new Thread(consumer,"消費者B").start();
}
}
//店員
class Clerk{
private int product = 0;//共享數據
public synchronized void get(){ //進貨
if(product >= 10){
System.out.println("產品已滿");
}else {
System.out.println(Thread.currentThread().getName()+":"+ (++product));
}
}
public synchronized void sell(){//賣貨
if (product <= 0){
System.out.println("缺貨");
}else {
System.out.println(Thread.currentThread().getName()+":"+ (--product));
}
}
}
//生產者
class Productor implements Runnable{
private Clerk clerk;
public Productor(Clerk clerk){
this.clerk = clerk;
}
@Override
public void run() {
for (int i = 0;i<20;i++){
clerk.get();
}
}
}
//消費者
class Consumer implements Runnable{
private Clerk clerk;
public Consumer(Clerk clerk){
this.clerk = clerk;
}
@Override
public void run() {
for (int i = 0;i<20;i++){
clerk.sell();
}
}
}
這就是生產消費模式的案例,這里沒有使用等待喚醒機制,運行結果就是即使是缺貨狀態,它也會不斷的去消費,也會一直打印“缺貨”,即使是產品已滿狀態,也會不斷地進貨。用等待喚醒機制改進:
//店員
class Clerk{
private int product = 0;//共享數據
public synchronized void get(){ //進貨
if(product >= 10){
System.out.println("產品已滿");
try {
this.wait();//滿了就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
System.out.println(Thread.currentThread().getName()+":"+ (++product));
this.notifyAll();//沒滿就可以進貨
}
}
public synchronized void sell(){//賣貨
if (product <= 0){
System.out.println("缺貨");
try {
this.wait();//缺貨就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
System.out.println(Thread.currentThread().getName()+":"+ (--product));
this.notifyAll();//不缺貨就可以賣
}
}
}
這樣就不會出現上述問題了。沒有的時候就生產,生產滿了就通知消費,消費完了再通知生產。但是這樣還是有點問題,將上述代碼做如下改動:
if(product >= 1){ //把原來的10改成1
System.out.println("產品已滿");
......
public void run() {
try {
Thread.sleep(200);//睡0.2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0;i<20;i++){
clerk.sell();
}
}
就做這兩處修改,再次運行,發現雖然結果沒問題,但是程序卻一直沒停下來。出現這種情況是因為有一個線程在等待,而另一個線程沒有執行機會了,喚醒不了這個等待的線程了,所以程序就無法結束。解決辦法就是把get和sell方法里面的else去掉,不要用else包起來。但是,即使這樣,如果再多加兩個線程,就會出現負數了。
new Thread(productor, "生產者C").start();
new Thread(consumer, "消費者D").start();
運行結果:
一個消費者線程搶到執行權,發現product是0,就等待,這個時候,另一個消費者又搶到了執行權,product是0,還是等待,此時兩個消費者線程在同一處等待。然后當生產者生產了一個product后,就會喚醒兩個消費者,發現product是1,同時消費,結果就出現了0和-1。這就是虛假喚醒。解決辦法就是把if判斷改成while。如下:
public synchronized void get() { //進貨
while (product >= 1) {
System.out.println("產品已滿");
try {
this.wait();//滿了就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":" + (++product));
this.notifyAll();//沒滿就可以進貨
}
public synchronized void sell() {//賣貨
while (product <= 0) {//為了避免虛假喚醒問題,wait方法應該總是在循環中使用
System.out.println("缺貨");
try {
this.wait();//缺貨就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":" + (--product));
this.notifyAll();//不缺貨就可以賣
}
只需要把if改成while,每次都再去判斷一下,就可以了。
2、用Lock鎖實現等待喚醒:
class Clerk {
private int product = 0;//共享數據
private Lock lock = new ReentrantLock();//創建鎖對象
private Condition condition = lock.newCondition();//獲取condition實例
public void get() { //進貨
lock.lock();//上鎖
try {
while (product >= 1) {
System.out.println("產品已滿");
try {
condition.await();//滿了就等待
} catch (InterruptedException e) {
}
}
System.out.println(Thread.currentThread().getName() + ":" + (++product));
condition.signalAll();//沒滿就可以進貨
}finally {
lock.unlock();//釋放鎖
}
}
public void sell() {//賣貨
lock.lock();//上鎖
try {
while (product <= 0) {
System.out.println("缺貨");
try {
condition.await();//缺貨就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":" + (--product));
condition.signalAll();//不缺貨就可以賣
}finally {
lock.unlock();//釋放鎖
}
}
}
使用lock同步鎖,就不需要sychronized關鍵字了,需要創建lock對象和condition實例。condition的await()方法、signal()方法和signalAll()方法分別與wait()方法、notify()方法和notifyAll()方法對應。
3、線程按序交替:
首先來看一道題:
編寫一個程序,開啟 3 個線程,這三個線程的 ID 分別為 A、B、C,
每個線程將自己的 ID 在屏幕上打印 10 遍,要求輸出的結果必須按順序顯示。
如:ABCABCABC…… 依次遞歸
分析:
線程本來是搶占式進行的,要按序交替,所以必須實現線程通信,
那就要用到等待喚醒。可以使用同步方法,也可以用同步鎖。
編碼實現:
public class TestLoopPrint {
public static void main(String[] args) {
AlternationDemo ad = new AlternationDemo();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
ad.loopA();
}
}
}, "A").start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
ad.loopB();
}
}
}, "B").start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
ad.loopC();
}
}
}, "C").start();
}
}
class AlternationDemo {
private int number = 1;//當前正在執行的線程的標記
private Lock lock = new ReentrantLock();
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
Condition condition3 = lock.newCondition();
public void loopA() {
lock.lock();
try {
if (number != 1) { //判斷
condition1.await();
}
System.out.println(Thread.currentThread().getName());//打印
number = 2;
condition2.signal();
} catch (Exception e) {
} finally {
lock.unlock();
}
}
public void loopB() {
lock.lock();
try {
if (number != 2) { //判斷
condition2.await();
}
System.out.println(Thread.currentThread().getName());//打印
number = 3;
condition3.signal();
} catch (Exception e) {
} finally {
lock.unlock();
}
}
public void loopC() {
lock.lock();
try {
if (number != 3) { //判斷
condition3.await();
}
System.out.println(Thread.currentThread().getName());//打印
number = 1;
condition1.signal();
} catch (Exception e) {
} finally {
lock.unlock();
}
}
}
以上編碼就滿足需求。創建三個線程,分別調用loopA、loopB和loopC方法,這三個線程使用condition進行通信。
八、ReadWriterLock讀寫鎖
我們在讀數據的時候,可以多個線程同時讀,不會出現問題,但是寫數據的時候,如果多個線程同時寫數據,那么到底是寫入哪個線程的數據呢?所以,如果有兩個線程,寫寫/讀寫需要互斥,讀讀不需要互斥。這個時候可以用讀寫鎖。看例子:
public class TestReadWriterLock {
public static void main(String[] args){
ReadWriterLockDemo rw = new ReadWriterLockDemo();
new Thread(new Runnable() {//一個線程寫
@Override
public void run() {
rw.set((int)Math.random()*101);
}
},"write:").start();
for (int i = 0;i<100;i++){//100個線程讀
Runnable runnable = () -> rw.get();
Thread thread = new Thread(runnable);
thread.start();
}
}
}
class ReadWriterLockDemo{
private int number = 0;
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//讀(可以多個線程同時操作)
public void get(){
readWriteLock.readLock().lock();//上鎖
try {
System.out.println(Thread.currentThread().getName()+":"+number);
}finally {
readWriteLock.readLock().unlock();//釋放鎖
}
}
//寫(一次只能有一個線程操作)
public void set(int number){
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName());
this.number = number;
}finally {
readWriteLock.writeLock().unlock();
}
}
}
這個就是讀寫鎖的用法。上面的代碼實現了一個線程寫,一百個線程同時讀的操作。
九、線程池
我們使用線程時,需要new一個,用完了又要銷毀,這樣頻繁的創建銷毀也很耗資源,所以就提供了線程池。道理和連接池差不多,連接池是為了避免頻繁的創建和釋放連接,所以在連接池中就有一定數量的連接,要用時從連接池拿出,用完歸還給連接池。線程池也一樣。線程池中有一個線程隊列,里面保存著所有等待狀態的線程。下面來看一下用法:
public class TestThreadPool {
public static void main(String[] args) {
ThreadPoolDemo tp = new ThreadPoolDemo();
//1.創建線程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2.為線程池中的線程分配任務
pool.submit(tp);
//3.關閉線程池
pool.shutdown();
}
}
class ThreadPoolDemo implements Runnable {
private int i = 0;
@Override
public void run() {
while (i < 100) {
System.out.println(Thread.currentThread().getName() + ":" + (i++));
}
}
}
線程池用法很簡單,分為三步。首先用工具類Executors創建線程池,然后給線程池分配任務,最后關閉線程池就行了。
總結:
以上為本文全部內容,涉及到了JUC的大部分內容。 本人也是初次接觸,如有錯誤,希望大佬指點一二!