线程2(Thread)

一、Thread类及常见的方法

1.Thread 的常见构造方法

方法说明
Thread()创建线程对象
Thread(Runnable target)使用 Runnable 对象创建线程对象
Thread(String name)创建线程对象,并命名
Thread(Runnable target, String name)使用 Runnable 对象创建线程对象,并命名
【了解】Thread(ThreadGroup group,Runnable target)线程可以被用来分组管理,分好的组即使线程组,这个目前我们了解即可
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

2.Thread 的几个常见属性

属性获取方法
IDgetId()
名称getName()
状态getState()
优先级getPriority()
是否后台线程isDaemon()
是否存活isAlive()
是否被中断isInterrupted()
  • ID 是线程的唯一标识,不同线程不会重复
  • 名称是各种调试工具用到
  • 状态表示线程当前所处的一个情况,下面我们会进一步说明
  • 优先级高的线程理论上来说更容易被调度到
  • 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
  • 是否存活,即简单的理解,为 run 方法是否运行结束了
  • 线程的中断问题,下面我们进一步说明
public class ThreadDemo6 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread("cxk") {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println(Thread.currentThread().getName());
                    // System.out.println(this.getName());
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                // run 方法的执行过程就代表着系统内线程的生命周期.
                // run 方法执行中, 内核的线程就存在.
                // run 方法执行完毕, 内核中的线程就随之销毁.
                System.out.println("线程要退出了!");
            }
        };

        // 这一组属性, 只要线程创建完毕, 属性就不变了.
        System.out.println(t.getName());
        System.out.println(t.getPriority());
        System.out.println(t.isDaemon());
        System.out.println(t.getId());
        // 这三属性会随着线程的运行过程而发生改变.
        System.out.println(t.isAlive());
        System.out.println(t.isInterrupted());
        System.out.println(t.getState());

        t.start();

        while (t.isAlive()) {
            System.out.println("cxk 线程正在运行!");
            System.out.println(t.getState());
            System.out.println(t.isInterrupted());
            Thread.sleep(300);
        }
    }
}
打印结果:
cxk
5
false
12
false
false
NEW
cxk 线程正在运行!
RUNNABLE
false
cxk

3.让线程中断的两种方式

中断让一个线程结束,有两种情况可能会发生:
1.已经把任务执行完了(run方法执行完),这种比较温和
2.任务执行了一半,被强制结束,(调用线程的inter),这种比较激烈

第一种:

public static boolean isQuit = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread() {
            @Override
            public void run() {
                while(!isQuit) {
                    System.out.println("别烦我, 我在忙着转账呢");
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("转账操作被终止.");
            }
        };
        t.start();
        Thread.sleep(5000);
        System.out.println("对方是内鬼, 赶快终止交易!!!");
        isQuit = true;
    }

解释:
在这里插入图片描述
运行结果:
在这里插入图片描述
注意:
1.如果没有sleep,主线程结束后新线程能否继续输出,这个不确定。
多线程之间是抢占式执行的。如果主线程中没有sleep,此时接下来CPU是执行主线程的isQuit=true,还是新线程的while循环,这是不确定的。

2.没有“子线程”这样的概念,只有多进程中涉及到了“父进程”和“子进程”。

3.sleep(5000)线程也不一定准确地休眠到5000ms,也会存在一定的误差,但是误差不会超过10ms,实际休眠5001,5002都是有可能的,但是不会是4999,因为线程的调度是有开销的,有可能在执行新线程的时候调度回主线程时的睡眠时间超过了5000ms,此时才执行下一步操作。

第二种:

public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread() {
            @Override
            public void run() {
                // 此处直接使用线程内部的标记位来判定.
                while (!Thread.currentThread().isInterrupted()) {
                    System.out.println("被管我, 我在忙着转账呢");
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        break;
                    }
                }
                System.out.println("转账被终止.");
            }
        };
        t.start();

        Thread.sleep(5000);
        System.out.println("对方是内鬼, 快终止交易!!!");
        t.interrupt();
    }

运行结果:
在这里插入图片描述
注:Thread.currentThread()获取的是当前线程的实例,当前代码中,相当于this,因此有些情况下可以直接用this表示当前线程的实例,也有些情况不能。
可以的有:如果是使用继承Thread的方式创建线程,这个操作就和this是一样的。
不可以的有:如果是使用Runnable的方式或者lambda的方式,此时就不能用this。

代码t.interrupt();有两种情况:

情况一:当前线程在sleep/wait等方法中阻塞时:
这个操作本质上是给该线程触发一个异常,即InterruptedException异常。此时线程内部就会收到这个异常,具体针对这个异常如何处理?这个是catch内部的事情。

情况二:当前线程不在sleep/wait等方法中阻塞时:
这个操作会给线程的Thread.currentThread().isInterrupted()置为true,这两个动作是同时执行的。

重点说明下第二种方法
1.通过 thread 对象调用 interrupt() 方法通知该线程停止运行
2.thread 收到通知的方式有两种:

  1. 如果线程调用了 wait/join/sleep 等方法而阻塞挂起,则以 InterruptedException 异常的形式通知,清除中断标志
  2. 否则,只是内部的一个中断标志被设置,thread 可以通过
    a) Thread.interrupted() 判断当前线程的中断标志被设置,清除中断标志
    b) Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置,不清除中断标志

第二种方式通知收到的更及时,即使线程正在 sleep 也可以马上收到。

对于实际情况,更优先考虑第一种,更容易保证原子性(忘了可以去查看事务的特性)。但是第二种也会有使用场景。如转账,它能够及时止损。

什么是清除中断标志?
线程的标志不是静态的,而是每个实例都对应着一个标志。若是静态的则一旦被设置为true则所有线程都会被终止了。
首先以Thread.interrupted()为例:

public static void main(String[] args) {
        Thread t = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println(Thread.interrupted());
                }
            }
        };
        t.start();
        t.interrupt();
    }

打印结果:
在这里插入图片描述
Thread.currentThread().isInterrupted()为例:
这种情况仅仅是判定标记位,而不会修改标记位。

public static void main(String[] args) {
        Thread t = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println(Thread.currentThread().isInterrupted());
                }
            }
        };
        t.start();
        t.interrupt();
    }

运行结果:
在这里插入图片描述

4.线程等待

线程之间是并行的关系,多个线程之间,谁先执行谁后执行,谁执行到哪让出CPU等等,都是无法感知和控制的,全权由系统内核来负责。

例如:创建一个新线程的时候,此时接下来是主线程继续执行还是新线程继续执行,这个是不好保证的。(这是抢占式执行的特点)。

虽然我们没法控制哪个线程先走哪个后走,但是可以控制让哪个线程先结束。

join方法——执行join方法的线程就会阻塞,一直阻塞到线程对应的线程结束后再继续执行下面的代码。它存在的意义就是为了控制线程结束的先后顺序。

例如:当第一次t1调用start的时候,主线程会同时调用t1的join方法,此时就会发生堵塞,当t1线程执行完之后,代码才轮到t2的start方法执行t2线程。

public class ThreadDemo10 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<10;i++){
                    System.out.println("这是线程1");
                }
            }
        };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<10;i++){
                    System.out.println("这是线程2");
                }
            }
        };
        t1.start();
        t1.join();
        t2.start();
        t2.join();
        System.out.println("主线程要结束了");
    }
}

运行结果:
在这里插入图片描述
如果将t1.join();t2.start();调换一下顺序,则运行的结果会截然不同。

运行结果:可以见到打印的结果是没有逻辑顺序可言的,因此它们是同时进行的,打印结果的差异是由于线程调度造成的。
在这里插入图片描述
如果线程结束了,才调用到join,则join方法也会立刻返回。

5.获取当前线程的引用

调用方式:Thread.currentThread(),返回的是线程对象的引用。在线程中断当中也提到过,有些时候可以用this代替。

例子:

public class ThreadDemo {
  public static void main(String[] args) {
    Thread thread = Thread.currentThread();
    System.out.println(thread.getName());
  }
}

6.休眠线程

我们已经知道了一个线程对应一个PCB,在内核态当中有分为就绪队列和阻塞队列,如果线程在正常运行计算判断逻辑,此时就是在就绪队列中排队。调度器就会从就绪队列中筛选出合适的PCB让它在CPU上执行。

如果某个线程调用sleep就会让对应的PCB进入到阻塞队列。进入阻塞队列是没有办法上CPU执行的。

对于sleep进入阻塞队列的时间是有限制的,时间到了之后,就自动被系统把这个PCB拿回到原来的就绪队列中去。

像join/wait/锁 也是可能导致线程被阻塞的,它们恢复条件各不相同。join被恢复的条件就是对应的线程结束。
在这里插入图片描述
例如:在线程A调用线程B的join方法,此时A就阻塞,一直阻塞到B这个线程执行结束。

二、线程状态

1.观察线程的所有状态

线程的状态是一个枚举类型 Thread.State

public class ThreadState {
   public static void main(String[] args) {
      for (Thread.State state : Thread.State.values()) {
         System.out.println(state);
      }
   }
}
打印结果:
NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED
  • NEW:Thread对象有了,内核中的线程(PCB)还没有。任务布置了,但是还没有开始执行。
  • RUNNABLE:就绪状态,当前线程正在CPU上执行或者已经准备好随时上到CPU中,有一个专门的就绪队列来维护。
  • BLOCK(等待锁)、WATING(wait)、TIME_WATING(sleep):阻塞状态,当前线程暂时停了下来,不会继续到CPU上执行,等到时机成熟才有机会执行。
  • TERMINATED:内核中的线程已经结束了(PCB没了),但是代码中的Thread对象还在,(这个对象要等GC(垃圾回收机制)来回收)。
  • isAlive:线程存活,除了NEW和TERMINATED之外,其它状态都表示线程存活。(PCB是否存活)。

2.线程状态和状态转移的意义

在这里插入图片描述
这张图主要就是围绕RUNNABLE去转化状态的。注意的是yield方法,它表示主动放权,让当前线程放弃CPU的执行权限,重新在就绪队列中排队。这个操作相当于sleep(0)。Java中运用它能够实现生成器,再实现协程。

例:

public class ThreadDemo12 {
    public static void main(String[] args) {
        Thread t = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<100_000;i++) {

                }
            }
        };
        System.out.println("线程运行前:"+t.getState());
        t.start();
        while(t.isAlive()) {
            System.out.println("线程运行当中:"+t.getState());
        }
        System.out.println("线程运行结束:"+t.getState());
    }
}

运行结果:
在这里插入图片描述
在这里插入图片描述

三、多线程带来的风险-线程安全(重点)

1.线程不安全的理解

线程安全:多线程并发执行某个代码,没有逻辑上的错误,就是“线程安全”。
线程不安全:多线程并发执行某个代码,产生逻辑上的错误,就是“线程不安全”。

例如:

public class ThreadDemo13 {
    static class Counter {
        public int count = 0;
        public void increase() {
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50_000;i++) {
                    counter.increase();
                }
            }
        };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50_000;i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:
在这里插入图片描述
按照逻辑上来说,它们虽然是并发执行,但结果应该为100000,那为什么最终的结果会比10w小呢?那最终结果的范围又是多少?

如果要理解清楚为什么最终结果小于10w,需要理清楚在累加的时候,内核中是如何处理的。

注:下图设置两个CPU是为了避免单个CPU时要记录上下文。
首先:对于上面的代码来说,调用increase方法分为三个步骤:
1.将内存中的数据加载到CPU中(load)
2.在CPU中把数据+1(increase)
3.把计算结束的数据写回到内存中(save)
在这里插入图片描述
如果两个线程是并行的,如:线程1进行++了一半的时候,线程2也在同时进行++。此时就会发现,明明CPU中自增了两次,但最终保存到内存中的结果仍然是1(这是线程不安全的情况)。因此必须要保证线程1 的save结束了,线程2再load,此时计算结果才是对的。但是这是不好保证的,由调度器来决定。

上面代码的例子并行执行的操作有很多种情况,如线程1与线程2同时load,则内存中count的结果还是1,再有线程1已经++线程2才开始load,最终的结果可能为1等等,情况是比较复杂的。

因此代码的最终执行结果的取值范围是5w到10w之间。极端情况下,t1和t2每次都是纯并行的,结果就是5w;若t1和t2每次都是纯串行的,结果就是10w。但是实际情况一般不会这么极端。调度过程中有时候是并行的,有时候是串行的,但是多少次串行多少次并行是不知道的,因此导致最终结果在5w到10w都是有可能的。

无论是后置++过程,还是前置++,读取数据的步骤和方式都是一模一样的。还要+=、-=、/=、*= 等。
但是对于赋值操作=
1.如果是针对内置类型来说,一般都是原子的(64位的CPU是这样的),如果是32位CPU,例如针对long来赋值,其实是分两步,高32位和低32位是分两次来赋值的。
2.如果是针对引用类型来说,就不一定了。

2.线程不安全的原因

1.线程是抢占式执行的(线程不安全的万恶之源)
线程之间的调度完全由内核负责,用户代码中感知不到,也无法控制。
线程之间谁先执行,谁后执行,谁执行到哪里从CPU上下来,这样的过程都是用户无法控制也无法感知的。
当线程或进程数目越多的时候,调度开销就越大。一个线程或进程被两次调度上CPU执行的时间间隔就越长,长到一定程度,就会感知到程序开顿。为了衡量这件事,就把内核中的就绪队列的PCB的数目称为“系统负载”,PCB数目越大,系统负载越高,系统就越繁忙。系统负载是衡量服务器繁忙程度的重要指标。

2.自增操作不是原子的
原子性的意思是即使执行的过程中被调度器调换走但它会继续执行直到结束。虽然线程里的自增不是原子的,但是它有上下文能记录上一次切换走的位置继续执行。当CPU执行到上面三个步骤的任何一个步骤的时候,都可能会被调度器调走,让给其它线程来执行。

3.三个线程尝试修改同一个变量
a) 如果是一个线程修改一个变量,线程安全。
b) 如果多个线程读取同一个变量,线程安全。但是如果有多个线程,一个读取的变量,一个修改变量,则最后线程还是不安全的。(注意体会)
c) 如果多个线程修改不同的变量,线程安全。

4.内存可见性导致的线程安全问题。

5.指令重排序(Java的编译器在编译代码时,会针对指令进行优化,调整指令的先后顺序,保证原有逻辑不变的情况下,提高程序的运行效率)
现代的编译器优化能力都非常强,优化后的代码,会比优化前快很多。
但是多线程的优化是不容易实现的,可能会导致一些问题。

优化的举例:
在这里插入图片描述

3.解决线程不安全的问题

问题一:抢占式执行
这个是操作系统内核产生的,无法解决

问题二:自增操作是非原子性的
这个是可以解决的,令自增操作变为原子性的即可。要给自增操作加上锁来保证它的原子性。此处涉及到关键字——synchronized。它的英文原意为“同步”,但在计算机领域中,同步在很多场景下都会用到,不同场景下的意义也截然不同,因此一定要根据上下文来区分同步是什么意思。

问题三:多个线程同时修改一个变量
这个要看具体的需求,也是不一定的。

问题四:内存可见性导致的线程安全问题
要使用volatile关键字,修饰的共享变量,可以保证可见性,部分保证顺序性。它的作用是保持内存的可见性,禁止编译器在代码中读和写的优化。以下面这个为例子:

public class ThreadDemo15 {
    static class Counter {
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (counter.flag == 0) {

                }
                System.out.println("循环结束!");
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                System.out.println("请输入一个整数: ");
                counter.flag = scanner.nextInt();
            }
        };
        t2.start();
    }
}

此时,在线程2中将flag值改为非0的数字,那么按道理来说flag不再为0,则在线程1中就不进入循环,线程1代码执行结束并且打印“循环结束”。

但是我们此时运行的结果:可以看到即使我们输入了一个非0数字,但是运行的结果不会打印“循环结束”,就说明线程1的循环是没有结束的。那么为什么会出现这种情况呢?
在这里插入图片描述
主要原因就是我们说的编译器优化导致的内存可见性问题。
在这里插入图片描述
线程1的核心代码中,循环其实什么也没干,而是反复快速地执行循环条件中的比较操作。具体操作:1.先从内存中读取flag的值到CPU中。2.在CPU中比较这个值和0的大小关系。

但是虽然读取内存的速度比读取磁盘快很多(3-4个数量级),从CPU的寄存器上读取数据速度比从内存中读数据还是要快很多。因此编译器判定这个逻辑中循环什么事也没干,只是频繁地读取内存而已。于是编译器就把这个读内存的操作优化了。第一次把内存数据读到CPU之后,后续就并不在内存上读了,而是直接取刚才在CPU中读取的数据。即使数据在内存中更新,因为此时数据在CPU上读取,因此数据不会随之更新

编译器实际上会认为falg没有改动,其实只是在线程中没有改动而已,而编译器又不能感知到其它线程是否对flag进行了修改。此处就出现了误判。

注:编译器的优化必须要保证一个前提:优化后的逻辑和优化前是等价的。但是此处是编译器的错误的优化导致程序出现了bug。

优化策略就涉及到内存可见性(总结):
如果优化生效,内存就是不可见的(其它线程修改了也不可见)。
如果优化不生效,内存就是可见的(其它线程修改了可见)。

为了能使得编译器不优化而使得内存可见性,则可以使用volatile关键字来处理。
将flag设置的类型前加volatile:
在这里插入图片描述
此时再运行:
在这里插入图片描述
加了volatile之后,对读取数据肯定是从内存中读取,不加volatile的时候,读取操作可能是从内存中读取,也可能是从CPU上读取。因为编译器的优化不是一定的,有几率会没做优化,但是为了保险起见最好加上volatile关键字来解决。

总结:线程不安全的场景下,要分清是要解决原子性还是内存可读性。
线程不安全的场景:
1.一个线程读,一个线程写:使用volatile解决。
2.两个线程写:加锁解决线程问题。

4.利用锁来解决操作的非原子性

Java中的锁(监视器锁 monitor)跟现实中的锁类似。
锁的特点:**能使得线程之间是互斥的,同一时刻只有一个线程能够获取到锁,其它的线程如果也尝试获取锁,就会发生阻塞等待。**一直等到刚才的线程释放锁,此时剩余的线程再重新竞争锁。

例如:在ATM机取款时,有一个人先抢到位置进去并把锁锁上,那么其它人就只能在外面等,等到里面的人开锁出来后,剩下的人再去竞争。以此类推。
在这里插入图片描述

锁的基本操作:
1.加锁(获取锁)lock
2.解锁(释放锁)unlock

加锁后代码如下:只不过在调用increase的方法最前头加上synchronized关键字即可。

public class ThreadDemo13 {
    static class Counter {
        public int count = 0;
        synchronized public void increase() {
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50_000;i++) {
                    counter.increase();
                }
            }
        };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50_000;i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

同样地,我们可以画时间线来理解加锁后的执行步骤:
因为加了锁的概念,因此每个线程都有加锁和解锁的操作。假设线程1获取到了锁,此时线程2再去尝试获取锁时,就会阻塞等待。线程1获取锁后后面的步骤都会保证原子性,继续执行下去。等到线程1解锁之后,线程2才可能会获取到锁。

此时线程1哪怕执行了一半被调度走了也没有关系,其它线程想尝试进行自增的操作,但会在lock的步骤被堵塞。这样也不会对线程1的修改操作产生任何负面的影响。

这样的话线程1的自增操作能够一鼓作气地执行完,中间不会受到干扰,也就相当于保证了自增操作的原子性。
在这里插入图片描述
获取锁也不一定是完全成功的。还会有几率获取不到。但是在获取到锁之后如果出了问题(因为其它原因导致长时间的阻塞)时,此时其它的线程也只能干等待。

这是多线程编程时会遇到的一个常见的困难,极端情况下可能会出现死锁的情况,一旦死锁,锁就永远也解不开了,程序就不能再进行下去,就只能重启了。

StringBuilder和StringBuffer:
StringBuilder
死锁问题的经典案例:
在这里插入图片描述
加锁的方法:
在这里插入图片描述
进入increase方法之前会先尝试加锁,increase方法执行完毕后会自动解锁。加锁解锁都由一个关键字来包办了,这样的好处就是避免出现忘记解锁的情况。

尝试加锁的时候不一定能立刻成功,如果发现当前的锁被占用了,该代码就会阻塞等待,一直等到之前的线程释放锁,才可能会获取到这个锁。

锁这个东西用起来没有那么容易:
1.使用的时候一定要注意按照正确的方式来使用,否则就容易出现各种问题。
2.一旦使用锁,这个代码基本上就和“高性能”无缘了。
锁的等待时间是不可控的(等到其它线程释放锁后才结束等待),可能会等很久。而加锁和解锁的时间不多。

5.理解synchronized的具体使用

用法可以灵活地指定某个对象来加锁,而不仅仅是把锁加到某个方法上。例如如果把synchronized关键字加到方法前,相当于给当前类的对象(this)加锁。所谓的加锁,实际上是给某个指定的对象来加锁。new出的对象会给这个对象申请一块内存空间。
new对象的内存空间示意图: (加锁状态也可以称为锁标记)。
在这里插入图片描述
再有上面自增操作的代码:
在这里插入图片描述
此处的synchronized关键字就是在针对counter这个对象来加锁。进入到increase方法内部,就把对象头里面的加锁状态设为true,increase方法结束后,就把加锁状态设为false。如果某个线程已经把加锁状态设为true,此时其它的线程尝试加锁就会阻塞等待。

synchronized几种常见的用法:
1.加到普通方法前:表示锁当前this。
2.加到静态方法前:表示锁当前类的类对象。(反射的实现依据就是类对象,类对象是JVM运行时把.class文件加载到内存中获取到的,称为类加载)
3.加到某个代码块之前,显示地指定给某个对象加锁。

当给某个对象加锁的实质:
在这里插入图片描述
例如:
当前这个代码中,两个线程尝试的是获取同一把锁。

public class ThreadDemo14 {
    public static void main(String[] args) {
        Object lock = new Object();
        Thread t1 = new Thread(){
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                synchronized (lock) {
                    System.out.println("请输入一个整数");
                    int num = scanner.nextInt(); // 用户如果不输入, 就会一直阻塞在 nextInt, 这个锁就会被一直占有
                    System.out.println("num = " + num);
                }
            }
        };
        t1.start();
        Thread t2 = new Thread(){
            @Override
            public void run() {
                while(true) {
                    synchronized (lock) {
                        System.out.println("线程2 获取到锁啦");
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };
        t2.start();
    }
}

运行结果:
在这里插入图片描述
一旦线程1获取到锁,并且没有释放的话,线程2就会一直在锁这里阻塞等待。此时还可以借助jconsole来查看当前线程的状态和属性。

打开jconsole后,这是线程1的调用栈,阻塞在nextInt方法,等待用户输入。当输入一个整数,让线程1释放锁之后,线程2才能继续执行。
在这里插入图片描述
这是线程2的调用栈:等待锁的线程会进入BLOCKED状态。
在这里插入图片描述
线程2阻塞在代码的第30行:
在这里插入图片描述

但是将上面的代码改一下,让线程1和线程2获得的不是同一把锁,那么就可以看到,线程1在获取到锁之后,线程2仍然在继续执行,两个线程之间是没有竞争的、没有互斥(用的两个对象来synchronized)

如:代码的内部细节跟上面的相同,只不过让线程1和线程2获取的不是同一把锁。
在这里插入图片描述
在这里插入图片描述
锁和对象是一一对应的,每个对象的对象头内部都有一个锁标记,在加锁的时候,一定要明确当前的代码是在给哪个对象进行加锁,以及需要思考清楚这样的操作能否起到互斥的效果。

当再把代码改为锁当前对象类对象:
在这里插入图片描述
在这里插入图片描述
那么线程1和线程2会不会有互斥现象出现呢?**答案是有的。每个类都有自己的类对象,此时因为lock1和lock2的类型相同,则类对象也相同,此处锁类对象的是同一把锁,它们之间就会产生竞争。**如果lock1和lock2的类型不相同,对应到的是两个类对象,则它们之间不会发生竞争。

6.对象等待集来干预线程的调度

引出对象等待集,是为了解决下面这个场景:
当一个线程霸占着CPU加锁后,但并没有满足自己的需求,而且解锁了。在解锁的同时它却又去加锁,如此反复,那么其它的线程就没有机会去获取锁了。这样的操作被称为“线程饿死”。其中涉及到wait和notify方法。

举个现实中的例子更好理解:
在这里插入图片描述
当我们用代码来演示:

public class ThreadDemo18 {
    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        System.out.println("等待前");
        obj.wait();
        System.out.println("等待后");
    }
}

运行结果:报错的异常为:非法的监视器锁状态异常
在这里插入图片描述
wait的工作流程:
1.释放锁
2.等待通知(过程可能很久,要等其它线程通知)
3.当收到通知后(notify),尝试重新获取锁,不一定会成功,继续往下执行。

wait方法必须要放在synchronized代码块内部使用,则得先有一个锁,它才能释放锁和有接下来的步骤。

改为:

public class ThreadDemo18 {
    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        synchronized (obj) {
            System.out.println("等待前");
            obj.wait();
            System.out.println("等待后");
        }
    }
}

观察线程的状态:能够发现wait进入阻塞等待时,线程状态就是WATING状态。还可以看到栈追踪的wait是一个本地方法(Java程序跑在JVM上,而JVM是使用C和C++实现的程序,当程序需要调用C++的代码,那C++的代码被称为本地方法)。本地方法的C++代码调用了操作系统的“条件变量”的API来实现wait方法的。
在这里插入图片描述
正确的代码演示
注:两个线程锁的对象必须是同一个对象,调用wait方法和notify方法的对象也必须是同一个对象

public class ThreadDemo17 {
    public static void main(String[] args) {
        Object locker = new Object();

        Thread t1 = new Thread() {
            @Override
            public void run() {
                synchronized (locker) {
                    while (true) {
                        try {
                            System.out.println("wait 开始");
                            locker.wait();
                            System.out.println("wait 结束");
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                System.out.println("输入任意一个整数, 继续执行 notify");
                int num = scanner.nextInt();
                synchronized (locker) {
                    System.out.println("notify 开始");
                    locker.notify();
                    System.out.println("notify 结束");
                }
            }
        };
        t2.start();
    }
}

运行结果:
在这里插入图片描述
时间线:
在这里插入图片描述
其实还有notifyAll方法,但是不太推荐使用。该方法是一下唤醒所有的线程,并且是所有的线程都去竞争同一把锁。

7.竞态条件问题

在上一个小节我们了解了wait方法的三个步骤:
1.释放锁
2.等待接收通知
3.收到通知后,尝试去获取锁

但是我们会发现,如果步骤1和步骤2分开后,在等待接收通知之前,其它的线程已经告知了,那么该线程再等待通知已经为时已晚。为了避免这样的问题,事实上,步骤1和步骤2着两个操作在wait方法中是原子进行的

实际上还是抢占式执行“惹的祸”,因为抢占式执行的执行顺序有太多种情况。

8.线程安全的大概总结

在这里插入图片描述

  • 54
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 36
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 36
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zjruiiiiii

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值