JavaEE_多线程(二)

发布于:2025-03-12 ⋅ 阅读:(11) ⋅ 点赞:(0)


1. 线程的状态

  1. NEW: Thread对象已经有了.但是内核里的PCB还没有(还没有调用start方法)
  2. TERMINATED: 内核PCB没了,线程结束了,Thread对象还在
  3. RUNNABLE: 就绪状态(线程正在CPU上运行,或者线程正在排队)
  4. WAITING: 由于wait这种不固定时间的方式产生的阻塞
  5. TIMED_WAITING: sleep 触发的线程阻塞
  6. BLOCKED: synchronized 触发的线程阻塞

2. 线程安全

一个代码,在多线程环境下执行不出bug就可以视为线程安全,反之,一个代码在单线程下执行的效果与多线程下执行的效果不一样,就可以视为线程不安全

我们先举一个线程不安全的例子,来直观的观察线程不安全

    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        // 等待两个线程全部执行完毕

        // 我们预期的结果是10W,但是实际上的结果一般是小于10W的
        // 并且每次执行的结果都不一样
        System.out.println("count:"+count);

    }

2.1 线程不安全问题的原因

接下来我们介绍一下线程不安全的原因

  1. 罪魁祸首是在操作系统中线程是抢占式执行的,随机调度的
  2. 多个线程同时针对一个变量进行修改
  3. 修改操作,不是原子的
  4. 内存可见性
  5. 指令重排序

3. 线程安全中的部分概念

3.1 原子性

什么是原子性
可以简单理解为一段代码要么全部执行,要么全部不执行
可以把一段代码想象成一个房间,每个线程都是一个想进房间的人,如果没有任何的保护机制,当A进入房间后,B也可以进入房间,此时就会破坏A的隐私了

那么不保证原子性会产生什么问题呢?
一条Java语句不一定是原子的,也不一定只是一条指令
比如一个简单的++操作
count++这个操作,站在CPU的角度上,是通过三个指令来完成的

  1. load: 把数据从内存读取到cpu寄存器中
  2. add: 把寄存器中的数据进行+1
  3. save: 把寄存器中的数据保存到内存中
    上述的代码在针对count进行修改的时候,单线程下并不会产生问题,但是在多线程下,两个线程的指令并不都是保持原子性执行的,这才导致了与预期不符的结果

3.2 可见性

可见性是指一个线程对共享变量值的修改,能够及时地被其他线程看到

主内存是指硬件角度的内存,工作内存则是指cpu寄存器和高速缓存
共享变量存在于主内存中, 每一个线程都有自己的工作内存,当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据,当线程要修改一个共享变量的时候,也会先修改工作内存的副本,再同步回主内存

当线程1修改了在它的工作内存中修改了共享变量a的值后,线程2的工作内存中a的值不一定会及时发生变化(因为主内存不一定能及时同步)这个时候代码就容易出现问题

3.3 指令重排序

本质上也是编译器的优化出现了问题,在单线程模式下,JVM和CPU指令集会进行优化,编译器对于指令重排序的前提是"保证逻辑不出现问题",这一点在单线程下是容易实现判断的,但是在多线程下就容易出现问题了,编译器很难在编译阶段对代码执行效果进行预测,因此指令重排序后很容易逻辑和之前不对等

4. 解决线程安全问题

4.1 synchronized关键字

synchronized一般要搭配{}代码块使用
当某个线程执行到某对象的synchronized时,被synchronized修饰的代码块就相当于被加锁了,此时其他线程也执行到同一个的synchronized就会产生阻塞等待,只有等上一个对象退出synchronized的时候,才会解锁

synchronized (锁对象) {
}

锁对象是谁并不重要,重要的是通过这个对象来区分两个线程是否竞争同一个锁,如果两个线程针对同一个对象进行加锁,就会产生锁竞争,一旦产生竞争,一个线程能拿到锁继续执行代码,一个线程拿不到锁,就只能阻塞等待,等前一个线程释放锁之后,他才有机会拿到锁
如果不是针对同一个对象进行加锁,就不会产生锁竞争

4.1.1 可重入

这里引入一个概念–死锁,顾名思义,就是两个或多个进程互相等待,谁也解不开锁
死锁的成因,涉及到四个必要条件

  1. 互斥使用(锁的基本特性): 当一个线程持有一把锁之后,另一个线程也想要获取到锁,就要阻塞等待
  2. 不可抢占(锁的基本特性): 当锁已经被线程1拿到之后,线程2只能等待线程1主动释放,不能强行抢过来
  3. 请求保持(代码结构): 一个线程尝试获取多把锁,先拿到锁1之后,在尝试获取锁2,获取锁2的时候锁1不会释放
  4. 循环等待(代码结构): 等待的依赖关系形成环了
    避免死锁的核心就是破除上述任意一个必要条件

但是synchronized是可重入锁,不会出现自己把自己锁死的情况
在可重入锁的内部,包含 线程持有者 和 计数器 两个信息
如果某个线程加锁的时候,发现锁已经被人占用了,而且恰好占用的正是自己,那么仍然可以继续获取到锁,并且计数器会加一
进一步的,无论锁有多少层,都是要在最外层才能释放锁.锁对象中,不光要记录谁拿到了锁,还要记录锁被加了几次,每加一次锁,计数器就+1,每解锁一次,计数器就-1,当出了最后一个大括号{},计数器恰好减成零,此时才会真正释放锁(才能被别的线程获取到)

常见死锁情况(不可重入锁)

  1. 一个线程,一把锁,连续加锁两次,就会死锁
  2. 两个线程,两把锁,线程1获取锁A,线程2获取锁B,此时1再尝试获取B,2再尝试获取A
  3. N个线程,M把锁,一种典型的情况,哲学家就餐问题

4.1.2 synchronized使用

有了加锁.就可以把一组不是原子的操作,变成"原子操作"

class Counter {

    synchronized public void increase() {
        // 1. 修饰普通方法:锁的Counter对象
    }

    public void increase2() {
        synchronized (this) {
            // 2. 明确指定锁那个对象:锁当前对象
        }
    }
    synchronized public static void increase3() {
        // 3. 修饰静态方法: 锁的Counter类对象
    }

    public static void increase4() {
        synchronized (Counter.class) {
            // 4. 明确指定锁那个对象: 锁类对象
        }
    }
}

我们要重点理解的是,锁的是哪个对象,两个线程竞争锁了同一个对象的锁才会出现竞争

4.2 volatile关键字

volatile关键字修饰的变量能保证内存可见性,禁止指令重排序
在这里插入图片描述
代码在写入volatile修饰的变量的时候:
改变线程工作内存中volatile变量副本的值,将修改后的副本的值从工作内存刷新到主内存

代码在读取volatile修饰的变量的时候:
从主内存中读取volatile变量的最新值到线程的工作内存中,从工作内存中读取volatile变量的副本
结合上面的图就可以看出,每次读取值的时候,都是最新的准确的变量的值,volatile保证了内存可见性,不过volatile强制读取内存,会比直接访问工作内存要慢很多,为了数据的准确性而牺牲了速度

4.2.1 volatile使用

    public static int isQuit = 0;
    //public static volatile int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                // do nothing
            }
            System.out.println("线程1结束");
        });

        t1.start();

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
           isQuit = scanner.nextInt();
        });
        t2.start();
        // 当我们输入0的时候,线程并不会结束,谪显然是一个bug
    }

这个例子就是典型的内存可见性问题,那为什么会产生内存可见性呢?

  1. load读取内存中isQuit的值到寄存器中
  2. 通过cmp指令比较寄存器的值是否是0,来决定是否该继续循环,但是由于while循环的非常快,短时间内就会进行大量load和cmp操作
  3. 此时编译器/JVM就发现,虽然进行了很多次的load操作.但是每次load操作的结果都是一样的,并且load操作又是比较费时间的,一次load操作花的时间相当于上万次的cmp了
  4. 所以编译器做了一个大胆的决定,只有第一次循环的时候,才读了内存,后续就不在读内存了,而是直接从寄存器中取出isQuit的值
    我们只需要给isQuit加上volatile关键字,就能解决这个问题了

5. wait和notify

由于线程是抢占式执行的,无法保证线程执行的先后顺序,但是在实际开发过程中,我们需要合理协调多个线程的执行先后顺序

5.1 wait()方法

wait要做的事情:

  1. 释放当前的锁(释放锁的前提是先加上锁),把线程放到等待队列中
  2. 让进程进入阻塞
  3. 当线程被唤醒的时候,重新获取锁

wait要搭配synchronized来使用,脱离synchronized使用wait会直接抛出异常
wait结束等待的条件:

  1. 其他线程调用该对象的notify方法
  2. wait等待时间超时(wait方法提供一个带有timeout参数的版本,来制定等待时间)
  3. 其他线程调用该等待线程的interrupt方法,导致wait抛出异常
Object object = new Object();
        synchronized (object) {
            System.out.println("wait之前");
            // 把wait要放到synchronized里面来调用,保证确实是拿到了锁
            object.wait();
            // 这里wait之后就会一直等待下去,这个时候就是用到了另一个唤醒方法notify
            System.out.println("wait之后");
        }

5.2 notify()方法

notify方法要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其他线程,唤醒等待的线程并使他们重新获取该对象的对象锁
如果有多个线程等待,则有线程调度器随机挑选出来呈wait状态的线程(并没有先来后到的规矩)
在notify方法后,当前线程不会马上释放该对象锁,要等到执行notify方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁

    public static void main(String[] args) {
        Object object = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (object) {
                System.out.println("执行之前");
                try {
                    object.wait();
                    //1. 释放当前的锁
                    //2. 让线程进入阻塞
                    //3. 当线程被唤醒的时候,重新获取锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("执行之后");
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (object) {
                System.out.println("进行通知");
                // 进行通知后,才会打印线程1中的 "执行之后"
                object.notify();
            }
        });
        t1.start();
        t2.start();
    }

notify只是唤醒某一个在等待的线程.此外还有notifyAll方法,可以一次性唤醒所有等待的线程. 虽然是同时唤醒多个线程,但是多个线程之间还是需要竞争锁,所以并不是同时执行,而是仍有先后的执行