多线程——多线程安全(synchronized和volatile)、wait和notify

文章详细讨论了线程不安全的原因,包括线程抢占执行、修改共享数据、非原子操作、内存可见性和指令重排序,并提供了synchronized关键字作为解决方案,解释了其互斥、内存刷新和可重入的特性以及使用方式。volatile关键字用于保证内存可见性,但不保证原子性。文章还介绍了wait和notify方法在多线程协作中的作用。
摘要由CSDN通过智能技术生成

目录

一、线程不安全的原因

1. 线程是抢占式执行的,线程间的调度充满的随机性。

2. 修改共享数据

3. 原子性:针对变量的操作不是原子的

解决方法:synchronized  加锁

4. 内存可见性

解决方法:synchronized 和 volatile

5. 指令重排序

解决方法:synchronized

二、synchronized 关键字 —— 监视器锁 monitor lock

1. synchronized 的特性

(1)互斥

(2)刷新内存(保证了内存可见性)

(3)可重入

2. synchronized 的使用方式:

(1)修饰一个普通方法

(2)修饰一个代码块

(3)修饰一个静态方法

3. Java 标准库中线程安全的类

三、volatile 关键字

1. JMM (Java Memory Model)(Java 内存模型)

2. volatile 和 synchronized

四、wait 方法 和 notify 方法

1. wait( )  方法

2.  notify( ) 方法

3. 基本用法

4. notifyAll( )  方法


一、线程不安全的原因

1. 线程是抢占式执行的,线程间的调度充满的随机性。

2. 修改共享数据

   多个线程修改同一个共享数据救护出现线程不安全的情况。(若多个线程读同一个数据 或者 修改各自的数据 则不会出现线程不安全的情况)

3. 原子性:针对变量的操作不是原子的

   以 count++ 这条语句举例,在计算机内部,这条操作分为了三个CPU指令:①load:把内存中的 count 的值,加载到 CPU 寄存器中;②add:把寄存器中的值 + 1;③save:把寄存器的值写回到 内存 的 count 中。

   在多线程情况下,是抢占式执行的。假设有两个线程,当两个线程“抢占式执行”,就导致了两个线程同时执行这三条指令时,顺序上充满了随机性

   当两个线程同时对 count++ 时,会出现当线程一还没将它寄存器中计算好的值放回内存时,线程二就将内存中的 count 值拿走了。假设 count = 0,使用线程一和线程二同时对 count++,预期结果是 count = 2,但是如果是上述情况的话,线程一拿走 0 到他的寄存器进行计算得 1,还没将 1 返回到内存中,线程二就像 内存 中 count 的值 0 拿到了他的寄存器中,然后再进行计算得 1,最后两个线程将其寄存器的值返回到内存中 的 count,结果是 1。明明加了两次,但结果还是 1。因此出现了线程不安全的情况。

   如下图所示:

public class Demo8 {
    //对同一个变量count进行 ++ 操作,使用两个线程同时加,预期正常结果为 10_0000
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                count++;
            }
        });
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

 但结果:

 可见是线程不安全的。

解决方法:synchronized  加锁

    在自增之前,先加锁,lock;在自增之后,再解锁,unlock;

    因为在实际开发中,一个线程中有很多任务。在这些任务中,可能只有任务4是线程不安全的,所以只对任务4进行加锁即可,而上面的任务1、任务2、任务3都是并发执行的。

   加锁的方法之一:synchronized 关键字

   给方法加上 synchronized 关键字,此时进入方法时,就会自动加锁离开方法,就会自动解锁。当一个线程加锁成功时,其他线程尝试加锁,就会触发阻塞等待(此时对应的线程就处于 BLOCKED 状态)。阻塞会一直持续到占用锁的线程把锁释放为止

class Counter{
    public int count;
    synchronized public void increase(){
        count++;
    }
}

public class Demo10 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();

            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t2.start();

        t1.join();
        t2.join();
        System.out.println(counter.count);

    }
}

4. 内存可见性

   假设针对同一个变量,一个线程 t1 进行读操作(循环进行很多次),一个线程 t2 进行修改操作(合适的时候进行一次)。

   t1 线程在循环读这个变量,这个变量在内存中。因为读取内存操作相比于读取寄存器操作来说要慢很多(慢 3 ~ 4 个人数量级),而此时 t2 右迟迟不进行修改,导致 t1 每次读到的数值都是同一个数值。因此就出现了Java编译器进行的代码优化,即不再从内存读数据,而是直接从寄存器里读值。一旦 t1 这样做,万一此时 t2 进行了修改,t1 就不能知道了。因此不是内存可见的。

    如下面代码所示,t 线程一直在飞速运转读取 isQuit 的值进行判断,经过优化后,t 线程直接在寄存器中读取了。而当我们输入isQuit 的值,isQuit 的值不再为 0 了,对应的 t 线程中循环判断条件为 false而退出循环,进而打印 “循环结束,t 线程退出”。但实际上并没有,t 线程仍然在运行中,则出现了线程不安全的情况。 

public class Demo9 {
    public static int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("循环结束,t线程退出");
        });
        t.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值:");
        isQuit = scanner.nextInt();
        System.out.println("main 线程执行完毕");
    }
}

结果:

解决方法:synchronized 和 volatile

(1)使用 synchronized 关键字

   synchronized 关键字不光能保证指令的 原子性,同时也能保证 内存可见性

   被 synchronized 包裹起来的代码,编译器就不敢轻易做出上述的假设(优化),相当于手动禁止了编译器的优化。

(2)使用 volatile 关键字

   volatile 和 原子性 无关,但是能够保证 内存可见性

   禁止编译器做出上面优化,编译器每次执行 判定相等,都会重新从 内存 中读取 isQuit 的值。

public class Demo9 {
    public static volatile int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("循环结束,t线程退出");
        });
        t.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值:");
        isQuit = scanner.nextInt();
        System.out.println("main 线程执行完毕");
    }
}

 结果:

5. 指令重排序

    指令重排序也会影响到线程安全问题。指令重排序也是编译器优化中的一种操作。

   对于我们平常写的代码,谁在前,谁在后无所谓,但是编译器不这样认为。编译器会智能的整理这些代码的前后顺序,从而提高程序的效率。(保证逻辑不变的前提下,去调整)

   对于单线程而言,编译器的判定是很准的;而对于多线程而言,编译器可能会产生误判。

解决方法:synchronized

   synchronized 不光能保证原子性,同时还能够保证内存可见性,同时还能禁止指令重排序

二、synchronized 关键字 —— 监视器锁 monitor lock

   解决线程不安全要从三个方面考虑:原子性、内存可见性、指令重排序。

   使用 synchronized 时,本质是哪个是针对某个对象进行加锁。而在代码的异常信息中,可能会出现 monitor lock 这个词。

   加锁操作是指在对象(实例)的 对象头 里 设置了一个标志位。在Java中,每个类都是继承自Object类。每个 new 出来的实例,里面一方面包含了你自己安排的属性,一方面包含了“对象头”,这个对象头中存储的是对象的一些 元数据 。

1. synchronized 的特性

(1)互斥

   使用 synchronized 会产生互斥的效果。进入 synchronized 修饰的代码块时,自动加锁,退出 synchronized 修饰的代码块时,自动解锁。此时其他的线程才有可能获得锁。

   使用时要注意:多个线程要针对同一个锁对象进行加锁才有用。

(2)刷新内存(保证了内存可见性)

   当一个线程获得了锁之后,它的大致执行流程:

  • 1. 获得互斥锁
  • 2. 从 主内存 中拷贝变量的值到 工作内存
  • 3. 执行代码,在其 工作内存 中改变变量的值
  • 4. 将更改后的值再 刷新 到 主内存 中
  • 5. 释放互斥锁

   将一系列操作进行了捆绑,实现了内存可见性。 

(3)可重入

   synchronized 实现的锁为 可重入锁,即 不会自己把自己锁死。

   当一个线程还没有释放锁,然后又尝试获取锁时,会出现如下情况:第一次获取锁成功,第二次获取锁时,锁还没有被释放,则该线程就会一直处于阻塞状态,直到锁被释放。但是此时 锁 已经在该线程中,释放锁也必须由该线程完成,但是此时该线程处于阻塞状态,就会造成 死锁 问题。

死锁 的 四个 必要条件:

互斥使用

   一个锁被一个线程占用了之后,其他线程占用不了。

不可抢占

   一个锁被一个线程占用了之后,其他线程不能把这个锁给抢走。

请求和保持

   当一个线程占用了多把锁之后,除非显示的释放锁,否则这些锁始终都是被该线程持有。

环路等待

   等待关系,成环了。(A 等 B,B 等 C,C 又等 A)

   而对于 synchronized 来说,不会出现这样的现象。在第二次尝试获取锁的时候又加了一次锁,相当于第一次锁没有释放,然后加了两次锁。

   在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息。此时有两种情况:

  • 当某个线程加锁时,发现锁已经被占用,但占用的线程恰好是自己(线程持有者),那么仍然可以继续获取到锁,同时让 计数器 自增
  • 当解锁的时候,不是单纯的代码执行完就解锁。而是当计数器减为 0 的时候,才真正的释放锁。(此时锁才能被别的线程获取到)

2. synchronized 的使用方式:

(1)修饰一个普通方法

   此时锁对象是 this

   例如上面的例子中:这里的这里的 synchronized 就是针对 this 来加锁,加锁的位置就是在设置 this 的对象头的标志位。

class Counter {
    public static int count;
    synchronized public static void increase() {
        count++;
    }
}

(2)修饰一个代码块

   要显示指定针对哪个对象加锁,Java中的任意一个对象都可以作为锁对象

   例如:

class Counter {
    public static int count;
    public void increase() {
        synchronized (this){
            count++;
        }
    }
}

(3)修饰一个静态方法

   对当先类的 类对象 加锁。以上面的例子来说,锁对象为 Counter.class,即 类名.class

   类对象:在运行程序中, .class 文件被加载到JVM内存中的模样。——> 反射机制。

   所谓的 “静态方法”  即 “类方法” ,而普通的方法为 “实例方法”。而静态方法中是没有对象实例的。因此锁对象为类对象。

   例如: 

class Demo {
    synchronized public static void fun() {
        System.out.println("111");
    }
}

 等价于

class Demo {
    public static void fun() {
        synchronized (Demo.class) {
            System.out.println("111");
        }
    }
}

3. Java 标准库中线程安全的类

  Java 有很多现成的类,有些是线程安全的,有些是线程不安全的。因此在多线程环境下,如果使用线程不安全的类,就需要小心谨慎。

线程不安全的类:

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

线程安全的类:这些关键方法上都有 synchronized,可以保证在多线程下,修改同一个对象没有问题。

  • Vector(不推荐使用)
  • HashTable(不推荐使用)
  • ConcurrentHashMap
  • StringBuffer
  • String :没有 synchronized,但是 String 是不可变对象(内部没有提供public 的修改属性的操作),无法在多个线程中同时修改同一个 String,因此是线程安全的。

三、volatile 关键字

   volatile 主要是阻止编译器优化,保存内存可见性。(例如在频繁读一个值时,依旧每次都从 内存 中读取)。不保证原子性。

1. JMM (Java Memory Model)(Java 内存模型)

   JMM就是将硬件结构,在Java中用专门的术语又重新抽象的封装了一遍。-> 主内存(内存)和工作内存(CPU、寄存器、缓存... 统称为工作内存)。

   因为Java是一个跨平台的变成语言,因此希望程序员在使用时,感知不到 CPU、内存等硬件设备的存在,所以要把硬件的细节封装起来。(假设某个计算机没有CPU,或没有内存,同样可以套在该模型中)

2. volatile 和 synchronized

   volatile 只保证 内存可见性,不保证原子性。只处理一个线程读,一个线程写的情况。

   synchronized 都能处理。(原子性,内存可见性,指令重排序)

四、wait 方法 和 notify 方法

   因为线程之间是抢占式执行的,充满了随机性。而实际开发中,我们需要让线程按照一定的顺序执行,因此可以使用 wait(等待) 和 notify(唤醒)。

   join 也是一种控制循环的方式,它更倾向于控制线程的结束。

    wait 和 notify 都是 Object 类的方法。调用 wait 方法的线程会陷入阻塞,阻塞到有其他线程通过 notify 来通知。

  • wait( ) /  wait( long timeout ) :让当前线程进入等待状态。
  • notify ( ) / notifyAll ( ) :唤醒在当前对象上等待的线程。

1. wait( )  方法

   调用 wait 方法后,内部会做三件事:

  • 1. 先释放锁;
  • 2. 等待其他线程的通知;
  • 3. 收到通知后,重新获取锁,并继续往下执行。

   可见要调用 wait 方法,前提是已经获取到锁了。即要搭配 synchronized 来进行使用。(notify 也要搭配 synchronized 来使用)

   例如: wait 哪个对象,就要对哪个对象加锁。

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait 前");
            //代码中调用了 wait 就会发生阻塞
            object.wait();
            System.out.println("wait 后");
        }
    }
}

2.  notify( ) 方法

   notify 方法是唤醒等待的线程。搭配 wait 方法(和 synchronized)使用。

   如下例,有两个线程,让第一个线程调用 wait 方法,第二个线程调用 notify 方法,观察打印结果。

public class Demo3 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized(locker) {
                System.out.println("wait 前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 后");
            }

        });
        t1.start();

        Thread.sleep(1000);

        Thread t2 = new Thread(() -> {
            synchronized(locker) {
                System.out.println("notify 前");
                locker.notify();
                System.out.println("notify 后");
            }
        });
        t2.start();
    }
}

结果:

    由上面例子可以看出,在 t1 线程调用 wait 后,t1 线程进入了阻塞状态。然后 main 线程休眠了1s后,开始执行 t2 线程,即开始调用 notify 方法,调用之后,t1 线程阻塞状态结束,继续执行代码,因此打印了 “wait 后”。

3. 基本用法

   如图,假设有两个线程(t1 和 t2),t1 里的任务:a、b、c, t2 里的任务:e、f、g。假设我们需要让两个线程按照:a -> e ; b -> f;c -> g 的顺序执行。

4. notifyAll( )  方法

   wait 和 notify 都是针对同一个对象来操作,而notifyAll 可以一次性唤醒所有的等待线程。而所有唤醒的线程之间仍然需要竞争锁。

   假设现在有一个对象 o ,并且有 10 个线程,都调用了 o. wait ,此时 10 个线程都是阻塞状态。

如果调用了 o.notify ,就会把 10 个其中的一个给唤醒。(唤醒哪个,不确定)

如果调用了 o.notifyAll,就会把所有的10个线程都唤醒,wait 唤醒后,会重新尝试获取锁(产生竞争)

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值