在这之前可以参照:Java多线程初阶(一)这篇文章🐻
目录
1. 线程的状态
2. 线程安全问题
2.1 引出线程安全问题
2.2 线程安全问题出现的原因
2.3 解决线程安全问题的方法
2.4 synchronized关键字详解
2.5 volatile关键字详解
3. wait方法和notify/notifyAll方法详解
1. 线程的状态
😄线程的状态在Java中就是一个枚举类型,我们可以输出这个枚举类中的类型观察线程总共有哪几种状态:
class ThreadStateTestDrive { public static void main(String[] args) { for(Thread.State state : Thread.State.values) { System.out.println(state); } } }
这些状态及这些状态的含义为:
- NEW:线程处于已创建的状态。线程对象在new之后,调用start方法之前都处于这个状态
- RUNNABLE:处于JVM中工作的线程都处于这个状态。这个状态又分为准备工作(ready)状态和工作中(running)状态
- BLOCKED:称为线程的阻塞状态。等待另一个线程执行任务的那个线程处于这个状态
- WAITING:等待状态。也是等待另一个线程执行特定动作的线程处于这个状态
- TIMED_WAITING:超时等待状态。也是等待另一个线程执行特定动作的线程处于这个状态,不同的是,处于这个状态的线程不会像等待状态的线程一样——死等
- TERMINATED:线程的终止状态。执行结束的线程处于这个状态。或者说我们自定义线程的run方法执行结束后,这个线程处于的状态。
这些线程之间的状态转换具有以下这张图上的关系(备忘):
2. 线程安全问题
2.1 引出线程安全问题
用一个栗子来引出并发环境下带来的安全问题并详细分析其中的原因:
class Number { public int number = 0; public void addNum() { synchronized (Number.class) { this.number++; } } } public class ThreadSafetyTestDrive { private static Number number = new Number(); public static void main(String[] args) throws InterruptedException { //创建两个线程对分别对Number对象中的num属性进行自增操作 Thread thread1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 50000; i++) { number.addNum(); } } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 50000; i++) { number.addNum(); } } }); //启动这两个线程 thread1.start(); thread2.start(); //等待这两个线程执行结束后再在main线程中输出Number实例中的num的值 thread1.join(); thread2.join(); System.out.println(number.number); //会发现输出的这个num的值在5w-10w之间,并不去顶具体是哪个值 } }
明明我们对同一个Number实例中的num实例进行了10w次自增操作,可为什么得到的值却不是10w?更过分的是,它还是一个每次程序运行不能确定的值!这是为什么呢?😂我们画张图来理解下这个过程。
2.2 线程安全问题出现的原因
- 线程是抢占式执行的,线程之间的调度充满了未知的随机性并且不受我们的控制,这是多线程并发环境下导致线程安全问题的万恶之源。
- 多个线程并发访问内存中的同一片内存空间,并且这片内存空间上操作不是原子性的,这就会出现多个线程同时修改同一片内存空间,造成不可预估的后果。
- 内存可见性。这是指编译器可能会对我们访问量高且需求量大的数据(Y)从内存中拷贝一份给放到CPU的寄存器当中进行读取,提升我们程序效率的一种机制。显然,这种机制在单线程环境中无疑是优异的,但是在多线程环境中,如果别的线程在某个时机修改了这个线程重复读取的这个值(Y),那么这个线程就不能及时的读取到被更新后的数据(Y')。这种在多线程环境中可能出现的问题就叫做内存可见性。由此导致的线程安全问题也是不可忽视的。
- 指令重排。这是指编译器会对我们的程序在保证逻辑不变的情况下进行某些操作的重排序以提高程序的执行效率,这就是所谓的指令重排,它也是编译器优化我们代码的一种机制。但是,在多线程环境下,编译器对“保证程序逻辑不变的情况下”进行操作的重排序是很难准确进行的。因此,这种激进方式的重排序很有可能会导致我们的程序也出现一些难以预知的效果。
下面这张图是对于由于内存可见性或者指令重排而导致线程安全问题的解释,原因2
就是我们在2.1中的引例,就不再画图了:
2.3 解决线程安全问题的方法
1.使用synchronized关键字
被synchronized修饰的方法、方法块或者对象具有互斥的特性。当某个线程执行到某个对象的synchronized时,其他线程也执行到了同一个对象的synchronized时就会阻塞等待。同时synchronized关键字除了能够实现指令的原子性外,还能够保证内存的可见性。
2.使用volatile关键字
volatile关键字用于修饰成员变量,被volatile关键字修饰的成员变量能够实现内存的可见性,但是volatile关键字不能保证变量的原子性。
2.4 synchronized关键字详解
😄synchronized本质上是要修改指定对象的对象头,也称为监视器锁(monitor lock)。因此synchronized必须要搭配一个对象来使用。synchronized对不同线程中的同一个对象之间具有竞态条件的原理如下:
😄synchronized的工作过程
- 获得互斥锁🔒。如果拿不到,则会处于阻塞状态
- 从主内存拷贝变量到自己的工作区域
- 执行代码并将更改后的共享变量的值刷新到主内存
- 释放互斥锁🔒
由上我们可以知道,synchronized也能够保证内存的可见性。但是在验证内存可见性时,我遇到的下面的这个问题困扰了我很久:
- 当我在一个线程中A中一直高速访问一个对象的成员变量,在某个时刻在另一个线程中B中修改了这个成员变量。启动A线程时run方法用了synchronized关键字修饰,按理来说,synchronized关键字能够实现内存可见性,那么它应当能够及时获取到成员变量被修改后的值,但是线程A仍然读取不到,这是为什么呢?
下面是出现这种问题的代码,thread-0线程指的是上面的A线程,main线程指的是上边的B线程,isQuit成员变量指的是上面的成员变量:
public class SynchronizedTestDrive { private static int isQuit = 0; public static void main(String[] args) { //创建一个新线程,不断访问我们的一个成员变量 Thread thread = new Thread(new Runnable() { @Override public synchronized void run() { while (true) { if (isQuit != 0) { break; } } System.out.println(Thread.currentThread().getName() + " 执行结束~"); } }, "thread-0"); thread.start(); //启动thread-0线程 try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } isQuit = 1; //在这里我将成员变量的值设置为了1,案例来说thread-0线程应该结束了。但是为什么程序一直不退出呢? System.out.println("main线程执行结束"); } }
分析问题:
😄synchronized的特性:
synchronized是可重入锁,具有可重入的特性。那么什么是可重入锁呢?
public class ReInLockTestDrive { static final Object object = new Object(); public static void main(String[] args) { synchronized (object) { System.out.println("层次-1"); synchronized (object) { System.out.println("层次-2"); } } } }
我们运行上面这段程序,发现并没有问题。按理来说外层的synchronized给Object对象加锁后,其他的程序就无法再次获得Object对象上的锁,但事实并不是这样,使用synchronized可以对同一个对象多次加锁。这就是synchronized的可重入性,我们常称它为可重入锁。
synchronized具有内存可见性。它具有刷新内存的功能,保证了内存的可见性。由于synchronized每次都从主内存加载变量到工作内存区并在执行完成后将工作内存区的内容刷新到主内存区,所以它是具有内存可见性的。
😄 synchronized的使用:
- synchronized修饰普通方法,相当于对方法中的当前对象上锁
public class SynchronizedTestDrive { public synchronized void test() { } }
synchronized修饰静态方法,相当于对当前类的类对象。值得注意的是,同一个类的所有对象都共享一个类对象,这相当于对该类的所有对象加锁,同一时刻,只能有一个对象获得执行指令上指令,其他对象排队获取,处于阻塞状态。
public class SynchronizedTestDrive { public static synchronized void test() { } }
synchronized修饰代码块,在代码块中指定要加锁的对象
//这和synchronized直接修饰普通方法的效果是一致的 public class SynchronizedTestDrive { public void test() { synchronized (this) { //针对当前对象加锁🔒 } } } //这和synchronized直接修饰静态方法的效果是一致的 public synchronized class SynchronizedTestDrive { public void test () { synchronized (SynchronizedTestDrive.class) { //针对当前类对象加锁🔒 } } }
2.5 volatile关键字详解
volatile关键字能够保证变量的内存可见性,但是不能保证对变量操作的原子性。它的工作过程如下:
- 代码在改变volatile修饰的变量的时候
①先改变工作内存中的变量副本的值;②将工作内存中变量副本的值刷新到主内存中- 代码在加载volatile修饰的变量的时候
①从主内存中将volatile变量的最新值加载到线程的工作区域当中;②从工作区域中读取volatile变量的副本这个关键字在上面已经介绍的很详细了,这里就不再写那么多啦。为成员变量加上volatile的实质是使得指令强制读取内存,虽然速度慢了,但是数据变得更加可靠!
3. wait方法和notify/notifyAll方法详解
1.wait方法
wait方法与sleep方法的功能类似,能够让当前调用的线程进入到等待状态。并且它需要与synchronized搭配使用,脱离synchronized的wait方法的调用会直接抛出监视器minitor状态异常。
方法 说明 public final void wait() throws InterruptedException 使调用线程进入永久等待状态,直到被notify或者notifyAll方法唤醒 public final native void wait(long timeout) throws InterruptedException 使调用线程等待timeout秒,如果再这个时间段内没有线程唤醒它,则到timeout时刻时自动唤醒 public final void wait(long timeout,int nanos) throws InterruptedException 和一个参数的方法类似,不过设置的等待时间更加精确 wait的工作过程:
- 使当前线程进入到等待状态(实质上是加入到CPU的等待队列中)
- 释放当前的锁
- 当被notify/notifyAll方法唤醒时,会重新尝试获得当前锁,然后继续执行
体会下wait方法的使用
没有搭配synchronized使用的wait方法抛出了运行了异常:
在thread-0线程中使用wait使得当前线程进入等待状态,在main线程中使用notify方法结束thread-0线程的wait方法:
public class WaitAPITestDrive { private static final Object object = new Object(); public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new Runnable() { @Override public void run() { //在这里获得object对象锁 synchronized (object) { System.out.println(Thread.currentThread().getName() + " 开始"); try { //在这里进入等待状态,失去object对象锁 object.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println(Thread.currentThread().getName() + " 结束"); } } }, "thread-0"); thread.start(); //启动线程 Thread.sleep(1000); //主线程休眠1s后进行thread-0线程的唤醒 synchronized (object) { //获得object对象锁 object.notify(); //唤醒thread-0线程。thread-0线程尝试获取这个obejct对象锁,然后继续执行 } System.out.println("main线程结束"); } }
wait方法使用的注意事项:
- wait方法需要在synchronized修饰的方法或者代码块中才能起作用,否则会抛出IllegalMonitorStateException异常信息
- wait方法的调用需要依赖指定的对象加锁对象,例如上面的例子中wait的执行是依靠object这个对象加减锁实现线程的暂停和被唤醒的。
2.notify方法(包括notifyAll方法)
方法 说明 public final native void notify() 唤醒等待当前对象锁的线程,具体的唤醒线程要取决于线程调度器,这个是随机的 public final native void notifyAll() 唤醒所有等待该对象锁的线程,具体哪个等待线程先获取到对象锁是不能确定的 notify/notifyAll方法也需要与synchronized搭配使用,它用于唤醒可能等待该加锁对象的对象锁的其他线程,并使得这些线程重新获得对象锁,唤醒它们的执行。如果同时又多个线程在等待这个对象锁,则由线程调度器重新挑选出一个处于等待该对象锁的线程并唤醒,这个“挑选”是随机的,并没有先来后到的说法。😕
具体的唤醒栗子参照上面1.wait方法介绍中的第二个例子,这里就不再举例了。