JavaEE初阶——多线程(3)线程安全

目录

一、线程安全

[1.1 线程不安全现象](#1.1 线程不安全现象)

[1.2 线程安全的概念](#1.2 线程安全的概念)

[1.3 线程不安全的原因](#1.3 线程不安全的原因)

[1.3.1 线程是抢占执行](#1.3.1 线程是抢占执行)

[1.3.2 修改共享数据](#1.3.2 修改共享数据)

[1.3.3 原子性](#1.3.3 原子性)

[1.3.4 内存可见性](#1.3.4 内存可见性)

[1.3.5 指令重排序](#1.3.5 指令重排序)

二、synchronized关键字

[2.1 互斥性](#2.1 互斥性)

[2.2 给代码加锁](#2.2 给代码加锁)

[2.2.1 给方法加锁](#2.2.1 给方法加锁)

[2.2.2 给代码块加锁](#2.2.2 给代码块加锁)

[2.3 加锁流程分析](#2.3 加锁流程分析)

[2.4 只给一个线程加锁](#2.4 只给一个线程加锁)

[2.5 判断线程竞争的锁](#2.5 判断线程竞争的锁)

[2.6 给方法上锁](#2.6 给方法上锁)

[2.6.1 不同实例对象的非静态方法](#2.6.1 不同实例对象的非静态方法)

[2.6.2 不同实例对象的静态方法](#2.6.2 不同实例对象的静态方法)

[2.7 给代码块上锁](#2.7 给代码块上锁)

[2.7.1 相同实例对象,单独锁对象](#2.7.1 相同实例对象,单独锁对象)

[2.7.2 不同实例对象,单独锁对象](#2.7.2 不同实例对象,单独锁对象)

[2.7.3 相同实例对象,不同方法,相同锁对象](#2.7.3 相同实例对象,不同方法,相同锁对象)

[2.7.4 不同实例对象,单独静态锁对象](#2.7.4 不同实例对象,单独静态锁对象)

[2.7.5 使用类对象作为锁对象](#2.7.5 使用类对象作为锁对象)

三、总结


一、线程安全

多线程为我们代码运行提高了效率,但是也同时带来了风险------线程不安全

1.1 线程不安全现象

我们通过让一个变量通过两个线程分别自增5万次,然后观察线程不安全的结果

java 复制代码
public class Demo1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter=new Counter();
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println("count="+counter.count);
    }
}

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

我们能看到运行结果为80677,我们预想的结果应该是count在两个线程中分别自增5万次, 结果应该是count=100000。我们的代码逻辑是正确的,而现在的运行结果与我们预想的不符,这种现象所表现出的问题就是"线程安全问题"

1.2 线程安全的概念

如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

1.3 线程不安全的原因

1.3.1 线程是抢占执行

线程是抢占执行的,也就是线程调度是随机的,执行顺序是不确定的, 这完全是CPU自己调度,我们无法解决随机调度这个情况,这是线程安全问题的罪魁祸首。随机调度会使程序在多线程环境下,执行顺序出现很多变数,而我们则需要保证程序在任意执行顺序都能正常工作,得到我们预期的结果

1.3.2 修改共享数据

当多个线程对同一个变量进行修改的时候,则会出现线程不安全的现象,在我们刚刚的代码中就是两个线程对共享变量count进行修改,导致出现了线程不安全。

  • 多个线程修改同一个变量,出现线程安全问题
  • 多个线程修改不同的变量,不会出现线程安全问题
  • 一个线程修改一个变量,也不会出现线程安全问题

1.3.3 原子性

原子性我们之前在数据库中提到事务的时候了解过,原子性是计指一个操作或一系列操作要么全部执行成功,要么全部不执行,不可被部分完成或中断。

我们可以把代码想象成一个房间,每个线程就是进入房间的人,如果A进入房间之后,还没有出来,这个时候B也可以进入房间,打断A在房间的工作,这样就不具备原子性。

现在回到我们之前的代码,count++看起来就是一条语句,要么执行+1,要么不执行保持原样,怎么还会被中断呢?

这是因为我们看到的一条Java语句不一定是原子的,不一定只是一条指令,我们看到的count++这条语句,在操作系统里面对应的是三条指令。

🌟:我们提前了解一下JMM:JMM是Java内存模型,简单来说,就是所有变量都是放到主内存,而每个线程都有自己的单独的工作内存,线程对变量的操作都是要从主线程拿变量值到自己的工作内存,在工作内存进行修改后,再放回主内存

我们此时再来了解上文说的三条指令

  • load(把count变量从主内存读到工作内存)
  • add(把count进行+1)
  • store(把更新之后的count从工作内存写回主内存)

又因为线程是抢占式执行的,此时两个线程执行count++的时候,这几条指令会出现不同的执行顺序,从而引发线程安全。

我们的理想情况是,两次count++操作,一个线程拿到1再加一得到2,另一线程拿到2加一得到3,最后写入内存的值为3。这三条指令,因为cpu随机调度,出现了不同顺序运行指令的情况,可以看到当指令运行顺序不同,可能两个线程从主内存load的值是同一个值1,add操作之后写入主内存的值都是2,此时就是因为原子性的问题导致出现线程安全的问题了,语句的执行被打断了!

我们上文的代码中count最后没有达到100000结果也是因为这个原因

1.3.4 内存可见性

还是我们上文提到的JMM内存模型,我们知道每个线程有自己的工作内存。在执行count++操作的时候,每个线程都是在各自的工作内存中操作,所以不同线程无法感知到变量的修改,

就像线程1已经在工作内存中把count改为2了,但是线程2工作内存中的count还是1,他没有感知到线程1的变量已经改变,这就是内存不可见

1.3.5 指令重排序

我们拿一个任务举例

  1. 前台取U盘
  2. 去办公室找老师
  3. 前台取快递

在单线程情况下,JVM、CPU指令集会对其进行优化,按照1->3->2的顺序执行,这样可以少跑一次前台,这种情况就是指令重排序。我们能看到,这样重排序的现象是没有破坏逻辑正确的。

编译器对于指令重排序的前提是"保持逻辑不发生变化",这一点在单线程的环境下容易判断,但是多线程环境下就没有那么容易了,代码的复杂程度更高,编译器在编译阶段无法准确对执行效果准确预测,所以我们要避免指令重排序

二、synchronized关键字

如果想要解决上文代码的线程不安全问题,我们引入锁的概念。在线程1执行任务之前上一把锁,执行任务的时候不允许别人打断,线程2此时如果想执行任务,也只能等待,线程1任务执行结束之后才释放锁。这个时候线程2拿到锁之后才执行线程2的任务。

在代码中给代码加锁我们使用synchronized关键字

2.1 互斥性

上面描述的不打断线程1的任务,线程2等待的现象,就是因为synchronized有互斥性,当线程执行到一个对象的synchronized中时,其他线程也执行到同一个对象的synchronized就会阻塞等待。

进入synchronized修饰的代码块,相当于加锁

退出synchronized修饰的代码块,相当解锁

2.2 给代码加锁

2.2.1 给方法加锁

java 复制代码
public class Demo_401 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter401 counter = new Counter401();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter401 {
    // 初始值是0
    public int count = 0;

    /**
     * 累加方法
     */
    public synchronized void increase () {
        count++;
    }
}

我们给increase()方法加锁,public sychronized void increase(),t1先获取到了锁,执行完成后释放锁,t2再获取锁,完成后释放锁。可以认为现在处于单线程运行的状态,从而解决线程安全问题。我们另外注意,如果synchronized修饰的是非静态方法,锁住的就是同一实例对象的此方法。

2.2.2 给代码块加锁

java 复制代码
public class Demo_402 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter402 counter = new Counter402();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter402 {
    // 初始值是0
    public int count = 0;

    /**
     * 累加方法
     */
    public void increase () {
        // 真实业务中,在执行加锁的代码块之前有很多的数据获取或其他的可以并行执行的逻辑
        // 1. 从数据库中查询数据 selectAll();
        // 2. 对数据进行处理 build();
        // 3. 其他的非修改共享变量的方法...


        // 当执行到修改共享变量的逻辑时,再加锁
        // 通过锁定代码块
        synchronized (this) {
            count++;
        }

        // 还有一些非修改共享变量的方法...

    }
}

我们刚刚直接把increase方法加锁,现在我们在方法内部对修改共享变量的代码块进行加锁,这样就不是我们刚刚上面提到的单线程情况了,方法内的代码块外还是多线程环境,执行到修改共享变量的部分再加锁,提高效率。这里的synchronized(this)锁定的是当前实例对象,相同实例对象调用会排斥,不同实例对象调用不排斥

2.3 加锁流程分析

我们要明白加锁不是代表CPU不再随机调度了,CPU仍是随机调度,只是调度到t2线程的时候会检查是否能获取锁,不能则等待,CPU再调度回t1执行。并且因为是随机调度,t1执行完释放锁之后仍然可能是t1拿到锁继续执行,不是一定就是轮到t2。

这样synchronized实现了原子性,保证任务不会被打断

同时,因为线程执行任务之前都要拿到上一个线程释放的锁,此时上一个线程已经实现了修改操作,并写回了主内存,所以当前线程读到的变量值永远都是修改后的值,也就是让线程的执行变成了串行,从而间接实现了内存可见性

🌟:我们没有主动去实现内存可见性,只不过是因为synchronized的特性简介实现了内存可见性,我们之后会学到另一个关键字主动解决内存可见性

但是synchronized不保证有序性,还是会发生指令重排序问题

2.4 只给一个线程加锁

java 复制代码
public class Demo_403 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter403 counter = new Counter403();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase1();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter403 {
    // 初始值是0
    public int count = 0;

    /**
     * 累加方法
     */
    public synchronized void increase () {
        count++;
    }

    public void increase1 () {
        count++;
    }
}

我们在Counter403中创建两个方法,一个上锁一个不上锁,同时两个线程,一个调用上锁的方法,一个调用不上锁的方法。此时两个线程不会出现锁竞争的情况,也就不会出现我

们上文描述的等待释放锁再执行(串行执行),所以此时结果仍是错误的。我们一定保证竞争的是同一把锁,这样才会出现我们之前描述的情况。

2.5 判断线程竞争的锁

锁中有个概念叫做锁对象 ,锁对象中会记录获取到锁的线程信息 ,会记录获取到锁的线程地址。锁对象就是一个简单对象,任何对象都可以是锁对象

我们可以采用Layout工具打印出来看简单了解一下

这里的0x00000082f6dff1f0就是当前获取到锁的线程地址

2.6 给方法上锁

2.6.1 不同实例对象的非静态方法

java 复制代码
public class Demo_404 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter404 counter = new Counter404();
        Counter404 counter1 = new Counter404();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter1.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter404 {
    // 初始值是0
    public static int count = 0;

    /**
     * 累加方法
     */
    public synchronized void increase () {
        count++;
    }
}

我们把count变量设为静态变量,不同实例对象调取increase方法也是对同一个对象自增。然后我们观察到synchronized修饰的是非静态方法,我们上文提到,修饰非静态方法的时候锁对象就是实例对象,t1是执行counter的increase方法,锁对象是counter实例对象,t2执行counter1的increase方法,锁对象是counter1对象。很明显两者竞争的不是同一把锁,没有锁竞争,结果自然也是错的

2.6.2 不同实例对象的静态方法

java 复制代码
public class Demo_404 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter404 counter = new Counter404();
        Counter404 counter1 = new Counter404();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter1.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter404 {
    // 初始值是0
    public static int count = 0;

    /**
     * 累加方法
     */
    public static synchronized void increase () {
        count++;
    }
}

我们在increase方法前加了static修饰,则为静态方法,属于类方法,此时锁对象就不是实例对象了,而是类对象,而全局之会有一个类对象。所以此时两个线程竞争的锁就是相同的了,此时结果正确

2.7 给代码块上锁

2.7.1 相同实例对象,单独锁对象

java 复制代码
public class Demo_405 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter405 counter = new Counter405();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter405 {
    // 初始值是0
    public static int count = 0;
    // 单独定义一个对象做为锁对象使用
    Object locker = new Object();
    /**
     * 累加方法
     */
    public void increase () {

        // 只锁定代码块
        synchronized (locker) {
            count++;
        }
    }
}

我们在类中单独定义一个Object类locker作为锁对象,在一个实例对象中,只会初始化一个locker对象,此时线程竞争的是同一个锁对象,结果正确

2.7.2 不同实例对象,单独锁对象

java 复制代码
public class Demo_406 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter406 counter = new Counter406();
        Counter406 counter1 = new Counter406();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter1.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);
    }
}

// 专门用来累的类
class Counter406 {
    // 初始值是0
    public static int count = 0;
    // 单独定义一个对象做为锁对象使用
    Object locker = new Object();
    /**
     * 累加方法
     */
    public void increase () {

        // 只锁定代码块
        synchronized (locker) {
            count++;
        }
    }
}

还是在类中单独创建一个Object类的locker对象作为锁对象,但是此时创建了两个实例对象,每个实例对象都会初始化一个locker对象,不同实例对象中的locker对象是不一样的,所以两个线程此时调用不同实例对象的方法,竞争的不是同一个locker锁对象,结果不正确

2.7.3 相同实例对象,不同方法,相同锁对象

java 复制代码
public class Demo_407 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter407 counter = new Counter407();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase1();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);
    }
}

// 专门用来累的类
class Counter407 {
    // 初始值是0
    public static int count = 0;


    // 单独定义一个对象做为锁对象使用
    Object locker = new Object();
    /**
     * 累加方法
     */
    public void increase () {

        // 只锁定代码块
        synchronized (locker) {
            count++;
        }
    }
    public void increase1 () {

        // 只锁定代码块
        synchronized (locker) {
            count++;
        }
    }
}

我们此时只创建一个实例对象,此时locker对象是同一个,并且作为两个方法的锁对象。虽然调用的不是同一个方法,但是锁对象相同,结果仍然相同。

2.7.4 不同实例对象,单独静态锁对象

java 复制代码
public class Demo_408 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter408 counter = new Counter408();
        Counter408 counter1 = new Counter408();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter1.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);
    }
}

// 专门用来累的类
class Counter408 {
    // 初始值是0
    public static int count = 0;

    // 全局变量,属于类对象
    static Object locker = new Object();
    /**
     * 累加方法
     */
    public void increase () {

        // 只锁定代码块
        synchronized (locker) {
            count++;
        }
    }
}

可以和2.7.2的代码进行对比,我们在locker对象前面加了static关键字,此时locker对象属于类,我们创建了两个实例对象,两个实例对象中的locker是同一个。我们此时让locker作为锁对象,两个线程竞争的是同一个锁,所以结果正确

2.7.5 使用类对象作为锁对象

java 复制代码
public class Demo_409 {
    public static void main(String[] args) throws InterruptedException {
        // 初始化累加对象
        Counter408 counter = new Counter408();
        Counter408 counter1 = new Counter408();

        // 创建两个线程对一个变量进时累加
        Thread t1 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });

        // 线程2
        Thread t2 = new Thread(() -> {
            // 5万次
            for (int i = 0; i < 50000; i++) {
                counter1.increase();
            }
        });

        // 启动线程
        t1.start();
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();

        // 查看运行结果
        System.out.println("count = " + counter.count);

    }
}

// 专门用来累的类
class Counter409 {
    // 初始值是0
    public static int count = 0;

    /**
     * 累加方法
     */
    public void increase () {

        // 只锁定代码块
        synchronized (Counter409.class) {
            count++;
        }
    }
}

我们使用Counter409.class来获得Class对象作为锁对象,这样的对象是全局唯一,顾名思义竞争的一定是同一个对象,所以结果正确

三、总结

  • 不同线程一定要竞争同一个锁对象才能正确运行
  • synchronized可以修饰方法:
  1. 修饰非静态方法时锁对象是当前实例对象
  2. 修饰静态方法时锁对象是类对象
  • synchronized可以修饰代码块
  1. synchronized(this) 锁对象是当前实例对象
  2. synchronized(locker) locker为非静态时作为锁对象,不同实例对象竞争的不是同一个锁对象
  3. synchronized(locker) locker为静态时作为锁对象,相同实例对象竞争的是同一个锁对象
  4. synchronized(类名.class) Class对象作为锁对象,全局唯一,竞争的一定是同一个锁对象
  • synchronized解决了原子性,间接解决了内存可见性,没有解决指令重排序
  • synchronized实现原子性并不是让CPU一直停留在当前线程,而是在另一个线程把CPU调度走的时候不能执行,只能等待。
相关推荐
Skrrapper3 小时前
【C++】C++ 中的 map
开发语言·c++
寄思~4 小时前
python批量读取word表格写入excel固定位置
开发语言·python·excel
workflower5 小时前
微软PM的来历
java·开发语言·算法·microsoft·django·结对编程
惊讶的猫5 小时前
c++基础
开发语言·c++
江湖一码农5 小时前
[小白]spring boot接入emqx
java·数据库·spring boot
人间乄惊鸿客6 小时前
python — day9
开发语言·python
妮妮喔妮6 小时前
Go的垃圾回收
开发语言·后端·golang
bbq粉刷匠7 小时前
从0开始学java--day6.5
java
向上的车轮9 小时前
无需云服务的家庭相册:OpenHarmony 上的 Rust 实践
开发语言·后端·rust