JAVA多线程——同步、通知/等待机制

目录

一、JAVA中的同步问题

1.1 几个概念

1.2 JAVA内存模型简介

二、锁

2.1锁的概念

2.2 乐观锁与悲观锁

2.3 公平锁、非公平锁

2.4 独享锁、共享锁

2.5可重入锁和非可重入锁

 三、 JAVA中的同步机制

3.1 ReentrantLock

3.2 synchronized 

3.3 ReentrantReadWriteLock

3.4 volatile

3.5 ThreadLocal

四、线程间的等待与通知机制

        4.1 隐式锁的等待/通知机制

                4.1.1 wait和notify/notifyAll方法

                4.1.2 wait/notify的运用

                4.1.3 隐式锁的等待/通知机制的推荐用法

4.2显式锁的等待/通知机制

总结


一、JAVA中的同步问题

如果有一个数据,多个线程都在对其进行修改/删除/读取等操作,会发生什么呢?可以想象到,每个线程操作的数据可能都不是最新的,这就会带来很多问题,如以下例子中,我们模拟了取钱的操作,定义了一个Money变量值为200,然后开了6个线程去取钱,代码如下:

package com.example.threadtest;
import static com.example.threadtest.MainClass.MONEY;
public class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
            getMoney();
    }

    private void getMoney() {
        String threadName = getName();
        System.out.println("线程"+threadName+"取款前余额MONEY="+MONEY);
        if (MONEY>0) {
            MONEY=MONEY-50;
            System.out.println("线程"+threadName+"取款50,剩余余额为"+MONEY);
        }else {
            System.out.println("线程"+threadName+"准备取款,余额不足,无法取款");
        }
    }
}

ppackage com.example.threadtest;
import java.util.concurrent.locks.ReentrantLock;

public class MainClass {
   public static int MONEY=200;
    public static void main(String[] args) throws Exception {
        //开6个线程去取钱
        MyThread myThread=new MyThread();
        MyThread myThread2=new MyThread();
        MyThread myThread3=new MyThread();
        MyThread myThread4=new MyThread();
        MyThread myThread5=new MyThread();
        MyThread myThread6=new MyThread();

        myThread.start();
        myThread2.start();
        myThread3.start();
        myThread4.start();
        myThread5.start();
        myThread6.start();
    }
}

代码执行后,打印的结果如下:

我们可以看出,有多个线程同时在同时操作MONEY字段,线程0和线程2已经各自取走了50块,可线程4访问时,查询到的余额还是200,这就产生了同步问题。

为了解决上述问题,JAVA中了多种机制来保证多个线程之间的同步关系

1.1 几个概念

原子性,这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。

CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

顺序性指的是,程序执行的顺序按照代码的先后顺序执行。

以下面这段代码为例

1
2
3
4

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

1.2 JAVA内存模型简介

在JAVA中,可以认为有一块所有线程共享的内存,通常称之为主内存,除此之外每个线程都有自己的一块私有的本地内存,如果线程需要使用主内存中的数据,会先将主内存中的数据拷贝一份副本保存到本地内存,线程对变量的所有操作都必须在私有本地内存中进行,而不能直接操作主内存中的变量。下图是JAVA内存模型的示意图,需要说明的是,上文提到的主内存和本地内存等都是抽象概念,并不一定就对应真实的cpu缓存和物理内存

 可以想象,如果有多个线程的运算任务都需要操作同一个共享数据时,由于线程都要先要拷贝一份要自己的本地内存,就可能导致某个线程拷贝的主内存数据副本是其他线程修改之前的值,这会导致严重的问题。为了防止这种情况的出现,JAVA中提供了很多锁和同步的机制来应对这种问题。

二、锁

2.1的概念

锁,在日常生活中随处可见。比如卫生间只能容纳一个人,进去一个人使用之后就上个锁,保证其他人进不来,避免尴尬,这个人使用完卫生间之后,再解锁,其他人就可以继续进来使用了。在JAVA中也是一样,当多个线程需要对共享的资源执行操作时,操作之前可以加个锁,操作完再解锁,就不会出现上文所述的同步问题,可以说,在多线程环境下,锁是一个必不可少的组件。锁有很多种种类,作用和使用场景也不尽相同,我们从不同的角度,可以对锁分为不同的类别,接下来先介绍一下锁的常见分类。

2.2 乐观锁与悲观锁

现实生活中,有的人很乐观,凡事都往好的地方想,也有的人很悲观,凡事都往最差的情况去考虑。锁也是一样,分为乐观锁和悲观锁。

乐观锁:总是假设最好的情况,每次有人去拿数据的时候,都认为别人只是拿不会修改,所以不会上锁,但是当有人要更新这个数据的时候,会判断一下在此期间有没有其他人更新过这个数据,如果更新过,就重试,以获得最新的数据,但是如果写操作过多,会一直重试,使用乐观锁反而影响效率,而需要读取数据的时候,不会被锁,所以乐观锁适用于读操作比较多,写操作比较少的场景,这样可以提高吞吐量。

悲观锁:总是假设最坏的情况,当A去拿数据的时候,会认为A一定会修改数据,所以会先上锁。这里若B也想去拿数据的,就会被一直阻塞,直到A拿完,B拿到锁,才能读取到数据(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。也就是说,为了保证安全,即使是读操作,也有可能会被阻塞,所以悲观锁适用于读少的场景

2.3 公平锁、非公平锁

在生活中,也有很多公平和非公平的例子。比如我们去银行办理业务,假设大家都信奉绝对的公平,那么每个人都应该按照先来后到的原则,老老实实按顺序取号,然后等着被叫号,到我们的号了,自然有工作人员叫我们去办理业务;可在实际情况中,往往没有绝对的公平,因为总有投机者喜欢先插个队试试,他们一进银行就直接跑到柜台办理业务,万一大家都比较软弱,都没意见,这个插队的人不用取号也不用等着叫号,就可以直接办理业务了,万一大家有意见,把插队的人一顿教育,他也只能乖乖去后面取号,再等着被叫号了;在锁里面也是一样,分为公平锁和非公平锁。

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。也就是说,大家都严格遵守规矩,老老实实排队,绝不插队。

优点:由于实行绝对的公平,只要大家按顺序排队,迟早能排到队首,因此所有的线程都能得到资源,不会被饿死在队列中。

缺点:队列里面除了第一个线程,其他的线程都会阻塞,队首的线程释放锁后,CPU每次都需要唤醒下一个线程来操作资源,CPU唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。也就是说,每当有人需要获取锁,先去最前面插个队,如果其他人没意见,就插队成功了,直接获取到锁;如果碰到硬气的,就是不让你插队,你再乖乖地跑到后面排队去。

优点:由于每个线程都会先主动“插队”一下,万一插队成功就可以直接操作资源了,不必所有线程都被动等待CPU去唤醒,这样一来可以减少CPU唤醒线程的开销,整体的吞吐效率会高点。

缺点:试想一下,万一总有人能插队成功,那么对于排在队列中间的那些线程就很不公平,它们可能就一直获取不到锁,或者长时间获取不到锁,导致饿死。

2.4 独享锁、共享锁

独占锁:独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得独享锁的线程即能读数据又能修改数据。

ReentrantLock 和 synchronized 都是独享锁

共享锁:共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加独享锁。获得共享锁的线程只能读数据,不能修改数据。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

ReentrantReadWriteLock:读锁是共享锁,写锁是独占锁。读锁的共享可以保证并发读是高效的,读写,写读,写写是互斥的

2.5可重入锁和非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

非可重入锁,和可重入锁相反,同一个线程内,即使外层方法已经获取了锁,内层方法也无法自动获取锁,必须等锁释放。然而实际上该对象锁已被当前线程所持有,且无法释放,所以此时会出现死锁。

除了上文介绍的锁的分类之外,还有很多种分类方式,这里贴出一张百度到的分类图(从哪里百度到的暂时忘了。。。找到后附上链接):

 三、 JAVA中的同步机制

JAVA中对锁有多种实现,也有很多手段力保证同步,接下来介绍比较典型的几种。

3.1 ReentrantLock

ReentrantLock,从中文翻译也能看出,它是支持可重入锁的锁,而且它还是一种独享锁。另外,从其构造方法还可以看出,该锁还支持获取锁时的公平和非公平选择,默认是非公平锁。

public ReentrantLock(boolean var1) {
    this.sync = (ReentrantLock.Sync)(var1 ? new ReentrantLock.FairSync() : new ReentrantLock.NonfairSync());
}

我们可以使用ReentrantLock把代码块保护起来,配合try-catch-finally使用,try之前上锁,try中写需要保护的代码,最后不要忘记在finally中解锁,基本用法如下:

Lock lock = new ReentrantLock();

lock.lock();

try {

  // update object state

}

finally {

  lock.unlock();

}

我们来使用ReentrantLock将上文中取钱的方法保护起来试试,也就是将取钱这个操作加锁,保证同一时刻只有一个线程在取钱,看结果是否还会有异常。


package com.example.threadtest;
import java.util.concurrent.locks.ReentrantLock;

public class MainClass {
   public static int MONEY=200;
    public static ReentrantLock MYLOCK=new ReentrantLock();
    public static void main(String[] args) throws Exception {
        MyThread myThread=new MyThread();
        MyThread myThread2=new MyThread();
        MyThread myThread3=new MyThread();
        MyThread myThread4=new MyThread();
        MyThread myThread5=new MyThread();
        MyThread myThread6=new MyThread();

        myThread.start();
        myThread2.start();
        myThread3.start();
        myThread4.start();
        myThread5.start();
        myThread6.start();
    }
}
    }
}
package com.example.threadtest;

import static com.example.threadtest.MainClass.MONEY;
import static com.example.threadtest.MainClass.MYLOCK;

public class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
        MYLOCK.lock();
        try {
            getMoney();
        } catch (Exception e) {

        } finally {
            MYLOCK.unlock();
        }
    }

    private void getMoney() {
        String threadName = getName();
        System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
        if (MONEY > 50) {
            MONEY = MONEY - 50;
            System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
        } else {
            System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
        }
    }
}

 从log中可以看到,使用ReentrantLock将取钱这个操作保护起来之后,没有再出现异常的数据,同一时刻只有一个线程能“取钱”。

3.2 synchronized 

1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;

synchronized (MyThread.class){
    getMoney();
}

2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;

private synchronized void getMoney() {
    String threadName = getName();
    System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
    if (MONEY >= 50) {
        MONEY = MONEY - 50;
        System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
    } else {
        System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
    }
}

3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;

private static synchronized void getMoney() {
    String threadName = getName();
    System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
    if (MONEY >= 50) {
        MONEY = MONEY - 50;
        System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
    } else {
        System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
    }
}

4. 修饰一个类,其作用的范围是synchronized后面小括号括起来的部分,作用主要的对象是这个类的所有对象。

private void getMoney() {
    synchronized (MyThread.class){
        String threadName = getName();
        System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
        if (MONEY >= 50) {
            MONEY = MONEY - 50;
            System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
        } else {
            System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
        } 
    }
}

接下来我们将上文中取钱这个例子用上synchronized,看是什么效果,我们先直接在方法前加上synchronized来修饰,主类中的内容和3.2.1节一样:

package com.example.threadtest;

import static com.example.threadtest.MainClass.MONEY;

public class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
        getMoney();
    }

    private synchronized void getMoney() {

        String threadName = getName();
        System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
        if (MONEY >= 50) {
            MONEY = MONEY - 50;
            System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
        } else {
            System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
        }
    }
}

通过log,我们发现,还是出现了多个线程同时读取数据的情况。这是因为getMoney这个方法是成员方法,在主类中创建了6个MyThread的对象,每次调用到的getMoney都是属于不同的MyThread对象,并不是互斥的关系。我们应该将getMoney方法设置为静态资源,让不同线程对象去调用同一个静态资源,这样才能达到效果。我们也可以声明一个静态对象,用该对象当锁,并将需要保护的逻辑修饰写在代码块中,实现同步。

package com.example.threadtest;

import static com.example.threadtest.MainClass.MONEY;

public class MyThread extends Thread {
    public static final String LOCK="lock";
    @Override
    public void run() {
        super.run();
        synchronized (LOCK){
            getMoney();
        }
    }

    private void getMoney() {
        String threadName = getName();
        System.out.println("线程" + threadName + "取款前余额MONEY=" + MONEY);
        if (MONEY >= 50) {
            MONEY = MONEY - 50;
            System.out.println("线程" + threadName + "取款50,剩余余额为" + MONEY);
        } else {
            System.out.println("线程" + threadName + "准备取款,余额不足,无法取款");
        }
    }
}

 

3.3 ReentrantReadWriteLock

上文介绍的ReentrantLock是独享锁,在同一时刻只允许一个线程进行访问。而接下来要介绍的读写锁,同一时刻可以允许多个读线程访问,但是在写线程访问的时候,所有的读线程和其它的写线程都会被阻塞。读写锁维护了一对锁,一个读锁一个写锁,通过分离读锁和写锁,使得并发性相比于一般的独享锁有了很大的提升。

一般情况下,读写锁的性能要比独享锁的性能好,因为大多数场景都是读多写少,在这种情况下,读写锁能够提供更好的并发性和吞吐量。java.util.concurrent包下的ReentrantReadWriteLock就是对读写锁的实现,接下来是一个例子,来说明读写锁的用法,代码中,我们创建了10个线程,5个线程去获取读锁,5个线程去获取写锁,获取到锁后,线程休眠5秒。

package com.example.threadtest;

import java.time.LocalDateTime;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadAndWriteLockDemo {

    public static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    public static Lock readLock = readWriteLock.readLock();
    public static Lock writeLock = readWriteLock.writeLock();

    public static void main(String[] args) {
        for(int i = 0;i < 10;i++){
            if(i%2 == 0){
                Thread readThread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "***请求获取读锁***");
                            readLock.lock();
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "***获取到读锁了***");
                            Thread.sleep(1000*5);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "***释放了读锁***");
                            readLock.unlock();
                        }
                    }
                });
                readThread.start();

            }else{
                Thread writeThread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "---请求获取写锁---");
                            writeLock.lock();
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "---获取到写锁了---");
                            Thread.sleep(1000*5);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            System.out.println(LocalDateTime.now() + ":"+Thread.currentThread().getName() + "---释放了写锁---");
                            writeLock.unlock();
                        }
                    }
                });
                writeThread.start();
            }
        }
    }
}

运行后打印的日志如下:

> Task :ThreadTest:ReadAndWriteLockDemo.main()

2021-07-13T16:03:29.687:Thread-1---请求获取写锁---

2021-07-13T16:03:29.687:Thread-6***请求获取读锁***

2021-07-13T16:03:29.687:Thread-8***请求获取读锁***

2021-07-13T16:03:29.687:Thread-3---请求获取写锁---

2021-07-13T16:03:29.687:Thread-7---请求获取写锁---

2021-07-13T16:03:29.687:Thread-2***请求获取读锁***

2021-07-13T16:03:29.687:Thread-5---请求获取写锁---

2021-07-13T16:03:29.687:Thread-4***请求获取读锁***

2021-07-13T16:03:29.687:Thread-0***请求获取读锁***

2021-07-13T16:03:29.687:Thread-9---请求获取写锁---

2021-07-13T16:03:29.687:Thread-1---获取到写锁了---

2021-07-13T16:03:34.700:Thread-1---释放了写锁---

2021-07-13T16:03:34.700:Thread-6***获取到读锁了***

2021-07-13T16:03:34.701:Thread-8***获取到读锁了***

2021-07-13T16:03:39.708:Thread-6***释放了读锁***

2021-07-13T16:03:39.708:Thread-8***释放了读锁***

2021-07-13T16:03:39.708:Thread-3---获取到写锁了---

2021-07-13T16:03:44.721:Thread-3---释放了写锁---

2021-07-13T16:03:44.721:Thread-7---获取到写锁了---

2021-07-13T16:03:49.721:Thread-7---释放了写锁---

2021-07-13T16:03:49.721:Thread-2***获取到读锁了***

2021-07-13T16:03:54.735:Thread-2***释放了读锁***

2021-07-13T16:03:54.735:Thread-5---获取到写锁了---

2021-07-13T16:03:59.738:Thread-5---释放了写锁---

2021-07-13T16:03:59.738:Thread-4***获取到读锁了***

2021-07-13T16:03:59.738:Thread-0***获取到读锁了***

2021-07-13T16:04:04.746:Thread-0***释放了读锁***

2021-07-13T16:04:04.746:Thread-4***释放了读锁***

2021-07-13T16:04:04.746:Thread-9---获取到写锁了---

2021-07-13T16:04:09.761:Thread-9---释放了写锁---

 从输出的log可以看出,可以有多个线程同时获取到读锁,但是同一时刻只能有一个线程获取到写锁。

3.4 volatile

若有一个共享变量,线程A先将其修改并保存在线程A的本地内存中,此时还未将这个修改后的新值同步到主内存中去,然而之前已经有一个线程B缓存了这个共享变量的旧值,那么两个线程访问到的就是值不一样的共享变量。这种问题使用上文中说介绍的各种加锁肯定是能够避免,不过如果现在线程B只是想在任何时候都读取到这个共享变量的最新值,那么但是使用上文介绍的各种锁就显得过于“重”了,比较浪费性能,这种情况下比较合理的方就是使用volatile关键字。为什么volatile关键字能读取到共享变量的最新值呢,是因为当某个线程对一个volatile修饰的变量进行写操作时,该线程的本地内存中的该变量的值会被立刻强制写入到主内存中去,同时写会操作之后,其他线程中缓存到的这个volatile共享变量的值会失效。感受到的效果就是,volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。

volatile关键字的一个经典用法就是配合锁,实现双重检查的单例模式。

在实现单例模式的时候,如果不考虑多线程的情况,初学者往往会写出以下代码:

package com.example.threadtest;
public class SingleClass {
    private static SingleClass singleInstance;
    public SingleClass(String threadName) {
        System.out.println(threadName + "---------------------创建了一个单例------------------------");
    }

    public static SingleClass getInstance(String threadName) {
        if (singleInstance == null) {
            singleInstance = new SingleClass(threadName);
        }
        return singleInstance;
    }
}

这样写,如果有多个线程同时调用getInstance方法,在判断if (singleInstance == null)的时候,可能还没有线程创建单例成功,所以都会执行singleInstance = new SingleClass(threadName);,导致创建多个单例,单例模式也就失去意义了。

为了避免这种情况,有人自然就想到了上面介绍的synchronized将创建实例的这部分代码锁起来,于是就改成了以下代码:

package com.example.threadtest;

public class SingleClass {
    private static SingleClass singleInstance;

    public SingleClass(String threadName) {
        System.out.println(threadName + "---------------------创建了一个单例------------------------");
    }

    public static SingleClass getInstance(String threadName) {
            synchronized (SingleClass.class){
                if (singleInstance==null){
                    singleInstance = new SingleClass(threadName);
                }
        }
        return singleInstance;
    }
}

这样写,在每次进入

if (singleInstance==null){
                    singleInstance = new SingleClass(threadName);
                }

之前都会先请求获取锁,这样当然是能够避免重复创建多个实例的情况,但是上文也说过synchronized同步代码块是一种比较"重"的同步机制,相对比较耗性能,下面介绍一种更优雅的方式。

package com.example.threadtest;

public class SingleClass {
    private volatile static SingleClass singleInstance;

    public SingleClass(String threadName) {
        System.out.println(threadName + "---------------------创建了一个单例------------------------");
    }

    public static SingleClass getInstance(String threadName) {
                if (singleInstance==null){
                    synchronized (SingleClass.class){
                        if (singleInstance==null) {
                            singleInstance = new SingleClass(threadName);
                        }
                }
        }
        return singleInstance;
    }
}

在这种单例的写法中,进行了两次判空,外层的判空了为了减少同步代码块的执行次数(上面说了synchronized比较耗性能),如果单例已经创建过,就不再创建了。但是,若有多个线程同时执行到外层判空的这个地方,但是目前还没有一个实例创建成功,那就意味着多个线程都可以进入同步代码块,所以需要在同步代码块中再加上一层判空,因为同步代码块中的内容只有一个线程可以执行,一旦有某个线程已经创建成功了,那么下一个进入该代码块的线程肯定判定实例不为空,自然也就不会再重复创建了。那为啥还要再private volatile static SingleClass singleInstance;单例前面加上volatile关键字呢,因为我们前面说过,volatile关键字修饰的变量能够让所有的线程其最新值,这就避免了某些情况下实例已经创建,但其他线程读取到的还是null,导致误空条件不准确的情况。

3.5 ThreadLocal

threadlocal而是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程内可以得到存储数据,线程外则不能访问到想要的值。其用法也比较简单,主要是get()、set()以及初始化initialValue()方法,这几个方法的作用,看名字就知道了,无需过多解释,接下来通过简单的例子介绍其用法;

package com.example.threadtest;

import java.util.Random;

public class MyThread extends Thread {
    private static ThreadLocal<Integer> threadLocalNum = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue() {
            return 1;
        }
    };

    public String getThreadName() {
        return getName();
    }

    @Override
    public void run() {
        super.run();
        threadLocalNumAdd();
    }

    private void threadLocalNumAdd() {
        double random = Math.random();
        random = random * 10;
        threadLocalNum.set(threadLocalNum.get() + (int) random);
        System.out.println(getName()+"将threadLocal增加了"+(int)random+"之后的值为,"+threadLocalNum.get());
    }
    public MyThread() {
        System.out.println("创建了线程"+getName());
    }
}

我们为在线程内创建了一个ThreadLocal变量,然后在run方法中为其增加一个随机整数,输出结果如下:

> Task :ThreadTest:MainClass.main()

创建了线程Thread-0

创建了线程Thread-1

创建了线程Thread-2

创建了线程Thread-3

创建了线程Thread-4

创建了线程Thread-5

创建了线程Thread-6

创建了线程Thread-7

创建了线程Thread-8

创建了线程Thread-9

创建了线程Thread-10

Thread-4将threadLocal增加了5之后的值为,6

Thread-9将threadLocal增加了0之后的值为,1

Thread-3将threadLocal增加了2之后的值为,3

Thread-1将threadLocal增加了4之后的值为,5

Thread-2将threadLocal增加了1之后的值为,2

Thread-0将threadLocal增加了5之后的值为,6

Thread-8将threadLocal增加了0之后的值为,1

Thread-7将threadLocal增加了2之后的值为,3

Thread-5将threadLocal增加了6之后的值为,7

Thread-6将threadLocal增加了2之后的值为,3

Thread-10将threadLocal增加了6之后的值为,7

创建了线程Thread-11

创建了线程Thread-12

Thread-11将threadLocal增加了9之后的值为,10

创建了线程Thread-13

Thread-12将threadLocal增加了3之后的值为,4

创建了线程Thread-14

Thread-13将threadLocal增加了2之后的值为,3

创建了线程Thread-15

Thread-14将threadLocal增加了6之后的值为,7

创建了线程Thread-16

创建了线程Thread-17

Thread-15将threadLocal增加了3之后的值为,4

Thread-16将threadLocal增加了6之后的值为,7

创建了线程Thread-18

Thread-17将threadLocal增加了3之后的值为,4

创建了线程Thread-19

Thread-18将threadLocal增加了7之后的值为,8

花费时间为:4

Thread-19将threadLocal增加了9之后的值为,10

BUILD SUCCESSFUL in 24s

从log中可以看出,每个线程的ThreadLocal变量都是独立的,值互不影响,其基本原理是,在每Thread 里面维护了一个ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了。

 从ThreadLocalMap源码可以看出,实例化ThreadLocalMap时创建了一个长度为16的Entry数组,然后通过某种运算计算出一个索引值var3,var3就是存储在table数组中的位置,也就是说每个线程Thread都持有一个Entry型的数组table,不同的线程操作的是不同的table。

四、线程间的等待与通知机制

4.1 隐式锁的等待/通知机制

若现在有一个资源,随时可能被其他线程修改,我们希望能够实时监控这个资源的状态,当这个资源是否达到某种条件时,触发一定的逻辑。一种比较笨的实现方式是,开一个线程,一直轮询这个资源的状态,如以下例子中的实现:

package com.example.threadtest.waitnotifytest;

import java.util.LinkedList;
import java.util.List;

public class YSList {
    public volatile static List<Integer> list = new LinkedList<>();

    public static void addList() {
        list.add(1);
    }
}
package com.example.threadtest.waitnotifytest;

public class AddListThread extends Thread {
    @Override
    public void run() {
        super.run();
        while (true) {
            YSList.addList();
            try {
                sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
package com.example.threadtest.waitnotifytest;

public class MonitorListThread extends Thread {

    @Override
    public void run() {
        super.run();
        while (true) {
            int size = YSList.list.size();
            System.out.println("监测到list中已有" + size + "个元素");
            if (size > 20) {
                System.out.println("停止监测");
                break;
            }
            try {
                sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

上述例子中,AddListThread线程会对list进行修改,另外MonitorListThread线程使用了死循环的方式,一直对list进行监控,虽然这样做,能够实现资源的实时监控,但是这样做无疑是特别占用资源的,因为监控线程一直在运行,即使list有很长时间没有被修改,监控线程也会一直去监控它。这就好比我们的手机没有响铃震动提示,需要我们用户一直盯着手机屏幕,看有没有人打电话过来,这样做虽然也能够接到电话,但无疑大部分时间都浪费掉了。如果我们能够理解wait/notify等方法,灵活运用线程间的等待/唤醒机制,那就优雅多了;

4.1.1 wait和notify/notifyAll方法

wait和notify都是Object中提供的接口线程方法,也就是说每个对象都有这俩方法,我们可以很方便地用一个Object对象当“锁”,需要注意的是,wait和notify方法都必须在同步代码块中使用,调用wait()方法和notify()方法之前,都需要首先获取到锁,4.1这一节我们主要介绍wait/notify配合synchronized实现隐式锁的等待与通知机制

wait方法的作用是:当在一个线程中执行某个实例对象的wait方法,那么这个线程就会变成等待状态,并将当前线程置入实例对象的等待队列中,直到被通知(notify)或被中断为止,同时释放掉在实例对象上的锁。

notify/notifyAll方法的作用是:唤醒某个实例对象等待队列中的线程,如果等待队列中有多个线程,会被随机唤醒一个,此处选择是不公平的,如果要唤醒等待队列中所有线程,可以使用notifyAll;

4.1.2 wait/notify的运用

4.1节刚开始,我们用比较笨的方式实现了对一个list的监控,接下来我们尝试使用wait和notify来更优雅地达到上文中的效果。基本思路是,MonitorListThread不必时刻都保持运行状态,只有当addListThread修改了list,调用notify唤醒了MonitorListThread的时候,才去监控list的状态,这样可以大大节省资源。代码如下:

package com.example.threadtest.waitnotifytest;

public class MainTest {
    public static Object LOCK = new Object();
    public static final int threshold=500;
    public static void main(String[] args) {
        AddListThread addListThread = new AddListThread();
        MonitorListThread monitorListThread = new MonitorListThread();
        addListThread.start();
        monitorListThread.start();
    }
}
package com.example.threadtest.waitnotifytest;

import java.util.LinkedList;
import java.util.List;

public class YSList {
    public volatile static List<Integer> list = new LinkedList<>();

    public static void addList() {
        list.add(1);
    }
    public static int size() {
        return list.size();
    }
}

package com.example.threadtest.waitnotifytest;

import static com.example.threadtest.waitnotifytest.MainTest.LOCK;
import static com.example.threadtest.waitnotifytest.MainTest.threshold;

public class AddListThread extends Thread {
    @Override
    public void run() {
        super.run();
        synchronized (LOCK){
            while (YSList.size()<threshold) {
                YSList.addList();
                System.out.println("AddList已将list已增加,当前大小" + YSList.size());
            }
            LOCK.notifyAll();
            System.out.println("list的size已达到"+threshold+",唤醒MonitorListThread");
        }
    }
}
package com.example.threadtest.waitnotifytest;

import static com.example.threadtest.waitnotifytest.MainTest.LOCK;
import static com.example.threadtest.waitnotifytest.MainTest.threshold;

public class MonitorListThread extends Thread {

    @Override
    public void run() {
        super.run();
        synchronized (LOCK){
            while (YSList.list.size()<threshold){
                waitThread();
                System.out.println("size尚未达到"+threshold+",放弃CPU,MonitorListThread进入等待状态");
            }
            System.out.println("MonitorListThread监测到list中已有" + YSList.list.size() + "个元素");
        }
    }

    private void waitThread() {
        try {
            LOCK.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

上述代码就是灵活运用wait/notify的一个例子,这种实现方式没有让MonitorListThread一直运行耗费CPU资源,而是让它不满足条件时,一直处于休眠状态,只有等到AddListThread通知后,才会被唤醒,并处理业务逻辑,运行结果如下:

> Task :ThreadTest:MainTest.main()

AddList已将list已增加,当前大小1

AddList已将list已增加,当前大小2

AddList已将list已增加,当前大小3

AddList已将list已增加,当前大小4

AddList已将list已增加,当前大小5

AddList已将list已增加,当前大小6

AddList已将list已增加,当前大小7

AddList已将list已增加,当前大小8

AddList已将list已增加,当前大小9

AddList已将list已增加,当前大小10

AddList已将list已增加,当前大小11

AddList已将list已增加,当前大小12

AddList已将list已增加,当前大小13

AddList已将list已增加,当前大小14

AddList已将list已增加,当前大小15

AddList已将list已增加,当前大小16

AddList已将list已增加,当前大小17

AddList已将list已增加,当前大小18

AddList已将list已增加,当前大小19

AddList已将list已增加,当前大小20

list的size已达到20,唤醒MonitorListThread

MonitorListThread监测到list中已有20个元素

BUILD SUCCESSFUL in 1s

3 actionable tasks: 3 executed

4.1.3 隐式锁的等待/通知机制的推荐用法

针对监控某个静态资源变化的需求,我们在通过4.1.1和在4.1.2中使用了两种实现方式,进行了对比,由结果易得知,使用等待/通知机制的效率较高,本节来总结一下等待/通知机制的推荐用法。

4.1.1中说过,无论是调用wait还是notify,都必须先获取到锁,然后等待方一般都应处于等待状态,只有收到通知的时候,才会被唤醒,被执行业务逻辑;作为通知方,在条件达到之后,应当及时通知等待方,这样才能够良好协作,等待方和通知方的执行步骤归纳如下:

等待方执行步骤:

 伪代码表示如下:

synchronized(LOCK){
while(条件不满足){
LOCK.wait();
}
//条件满足了
执行业务代码
}

 通知方执行步骤:

 伪代码表示如下:

synchronized(LOCK){
执行业务代码,使条件发生变化
LOCK.notify()/notifyAll();
}

 上文是线程间等待/通知机制的推荐用法。还有一个小问题需要说明,不知道是否有人会注意到,无论是通知方还是等待方,都需要先获取锁,那么万一等待方的条件一直不满足,会不会一直无法释放锁,造成通知方一直拿不到锁,导致死锁?答案是否定的,因为wait方法会释放掉锁,所以不会出现死锁的问题,而sleep方法是不会释放锁的,这也是wait方法和sleep方法的一个区别。

4.2显式锁的等待/通知机制

上文4.1节主要介绍wait/notify配合synchronized实现隐式锁的等待与通知机制,那如果要使用ReentrantLock等显式锁,该如何实现等待/通知机制呢,可以利用JAVA中提供的Condition接口。

我们可以看到Condition接口中提升了await()、signal()、signalAll()等方法,其实这几个方法的作用就类似于上文4.1节中所介绍的wait()、notify()、notifyAll()。根据具体业务需求,每个Lock对象可以利用newCondition()方法创建多个Condition对象,比如我们4.1.3中判断条件只有一个“size是否大于50”,所以就只需要创建一个Condition对象,如果有多个条件,可以创建多个Condition对象。显示锁的等待/通知机制大致的使用步骤和显示锁的基本一致,这里还是通过上面的案例来举例,不过这里换成显式锁的实现方式。

package com.example.threadtest.waitnotifytest;

public class MainTest {

    public static final int threshold=20;
    public static void main(String[] args) {
        AddListThread addListThread = new AddListThread();
        MonitorListThread monitorListThread = new MonitorListThread();
        addListThread.start();
        monitorListThread.start();
    }
}

package com.example.threadtest.waitnotifytest;

import java.util.LinkedList;
import java.util.List;

public class YSList {
    public volatile static List<Integer> list = new LinkedList<>();

    public static void addList() {
        list.add(1);
    }
    public static int size() {
        return list.size();
    }
}

package com.example.threadtest.waitnotifytest;

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

import static com.example.threadtest.waitnotifytest.MainTest.threshold;

public class AddListThread extends Thread {
    public static Lock LOCK = new ReentrantLock();
    public static Condition conditionSize = LOCK.newCondition();

    @Override
    public void run() {
        super.run();
        LOCK.lock();
        try {
            while (YSList.size() < threshold) {
                YSList.addList();
                System.out.println("AddList已将list已增加,当前大小" + YSList.size());
            }
//            LOCK.notifyAll()
            conditionSize.signalAll();
            System.out.println("list的size已达到" + threshold + ",唤醒MonitorListThread");

        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            LOCK.unlock();
        }
    }
}
package com.example.threadtest.waitnotifytest;

import static com.example.threadtest.waitnotifytest.AddListThread.LOCK;
import static com.example.threadtest.waitnotifytest.AddListThread.conditionSize;
import static com.example.threadtest.waitnotifytest.MainTest.threshold;

public class MonitorListThread extends Thread {

    @Override
    public void run() {
        super.run();
        LOCK.lock();
        try {
            while (YSList.list.size()<threshold){
                waitThread();
                System.out.println("size尚未达到"+threshold+",放弃CPU,MonitorListThread进入等待状态");
            }
            System.out.println("MonitorListThread监测到list中已有" + YSList.list.size() + "个元素");

        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            LOCK.unlock();
        }
    }

    private void waitThread() {
        try {
//            LOCK.wait();
            conditionSize.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果如下:

AddList已将list已增加,当前大小1

AddList已将list已增加,当前大小2

AddList已将list已增加,当前大小3

AddList已将list已增加,当前大小4

AddList已将list已增加,当前大小5

AddList已将list已增加,当前大小6

AddList已将list已增加,当前大小7

AddList已将list已增加,当前大小8

AddList已将list已增加,当前大小9

AddList已将list已增加,当前大小10

AddList已将list已增加,当前大小11

AddList已将list已增加,当前大小12

AddList已将list已增加,当前大小13

AddList已将list已增加,当前大小14

AddList已将list已增加,当前大小15

AddList已将list已增加,当前大小16

AddList已将list已增加,当前大小17

AddList已将list已增加,当前大小18

AddList已将list已增加,当前大小19

AddList已将list已增加,当前大小20

list的size已达到20,唤醒MonitorListThread

MonitorListThread监测到list中已有20个元素

BUILD SUCCESSFUL in 1s

总结

本文主要介绍了JAVA多线程中同步、锁、通知与等待的相关知识,后面会继续探讨更多关于JAVA多线程编程的知识,如有错误还请各位大神指出,同时也欢迎各位大佬来讨论和交流技术,本人邮箱hbutys@vip.qq.com

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值