《JavaSE-第二十二章》之线程安全问题

简介: 《JavaSE-第二十二章》之线程安全问题

文章目录

共享受限资源

什么是线程安全问题?

存在线程安全问题

线程不安全的原因

原子性

内存可见性

指令重排序

synchronized的特性

1. 互斥

2. 可重入

synchronized使用示例

1. 直接修饰普通方法 锁的 Counter对象

2. 修饰静态方法: 锁的 Counter类的对象

3. 修饰代码块:明确指定锁哪个对象

volatile

volatile与synchronized

共享受限资源

在单线程序中,只有一个线程在干活。因此不会存在多个线程试图同时使用同一个资源。这就好比,不允许两个人在同一个停车位停车,两个人同时使用一个坑位,甚至是两个人坐在公交车上的同一个位置。并发虽然能同时做多个事情,但是,多个线程彼此可能互相干涉。如果无法避免这种冲突,就可能会发生两个线程同时修改同一个变量,两个线程同时修改同一个支付宝账户,改变同一个值等诸如此类的问题。

什么是线程安全问题?

操作系统中的线程调度采取的是抢占式执行,多个线程的调度执行过程,可以视为"随机的",而这些线程可能会同时运行某段代码。程序每次运行的结果和单线程运行的结果是一样的,而且其他的变量和预期的也是一样的,就是线程安全的,反之就是线程不安全。

存在线程安全问题

考虑下面一个例子,两个线程对同一个变量自增,使得这个变量的值得到10000.

示例代码

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

运行结果:

很遗憾并没有达到我们的预期结果,之所以会这样是因为count++操作不是原子的,具体什么原子性以及如何解决,请看下文。

线程不安全的原因

1.多个线程同时修改同一个共享数据,如上述代码修改堆上的count

2.操作系统对于线程的调度是抢占式的

3.修改操作不是原子的

4.内存可见性问题

5.指令重排序

原子性

原子操作是不能被线程调度机制中断的操作;一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前(切换到其他线程执行)执行完毕。一句话就是要么不做,做的话就是一次性做完。

内存可见性

可见性指,一个线程对共享变量值 的修改,能够及时地被其他线程看到。在Java虚拟机中定义了Java内存模型,其目的就是屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

线程之间的共享变量存在 主内存 (Main Memory),实际上是内存。

每一个线程都有自己的 “工作内存” (Working Memory) ,这里的内存指的是CPU中 的寄存器或者高速缓存。

当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.。

当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存。

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。

初始情况:初始情况下, 两个线程的工作内存内容一致

线程1将空间中的a修改为25,线程1中的值不一定能及时同步到主内存中,对应的线程2的工作内促的值也不一定能及时同步。

为啥要这么麻烦的拷贝?

因为CPU访问自身的寄存器以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级),也就是几千倍,上万倍。

指令重排序

编译器在逻辑等价的前提下,调整代码的执行步骤来提高程序的运行效率。就像某一天你打算先去菜鸟拿U盘,然后回宿舍写作业,然后再和朋友一起去拿快递。这个事情就可以优化成先写作业,然后和朋友一起去菜鸟,顺便把U盘拿了。这样就可以少跑一次菜鸟,这就叫指令重排序。

案例分析

上述代码利用两个线程将一个变量从0自增到10000,但是实际值是小于10000。其原因是因为 线程调度是随机的,造成了线程自增操作的指令集交叉,从而导致实际值小于预期值,至于为啥会造成指令集交叉又因为count++这个操作不是原子的,不是原子意味着不是一气呵成的,而是由三步操作完成:

1.从内存把数据读到CPU中的寄存器,该操作记作load

2.对数据完成自增,.该操作记作add

3.把数据写会内存,该操作记作save

对一个数进行两次自增操作,初始值为0,目标值为2,两个线程并发执行,进行2次子自增。具体线程间指令集可能出现的情况如下:

情况1:线程之间指令集没有任何的交叉,实际值等于预期值。具体如下图所示

情况2:线程之间指令集存在交叉,实际值小于预期值。具体如下图所示

根据上面的分析可知,上述代码出现线程不安全的问题是线程的抢占式执行以及count++操作不是原子性的,由于线程调度是由操作系统所决定,我们无从干涉。那么就只能将不是原子性的操作打包成一个原子性的操作,这样无论线程如何随机的调度,都不会出现bug,至于如何打包,就得通过加锁来解决。

线程加锁

上述的案例告诉了我们一个使用线程的基本问题:你永远不知道一个线程什么时候运行,什么时候不运行。想象一下,你正在吃饭,当你拿筷子夹肉的时候,突然肉就消失不见了,因为你的线程被挂起了,而另一个人在你挂起的期间把那块肉吃了。对于并发工作,我们需要某种方式来防止两个任务同时访问相同的资源。解决这个冲突的方法就是当资源被一个任务使用时,在其上加锁,第一个访问的某项资源的任务必须锁定这个资源,使得其他的任务在被解锁之前,就无法访问它,而解锁之时,另一个任务就会锁定并使用它,以此类推。如果浴室是共享的受限资源,当你冲进去的时候,把门一关获取上锁,其他的人要使用浴室就只能被阻挡,所以就得在浴室门口等待,直到你使用完为止。在Java中提供了synchronized的形式,为防止资源冲突提供了内置支持。当任务要被执行synchronized关键字保护的代码片段时候,它将检查锁是否可用,然后获取锁,执行代码,释放锁。

synchronized的特性

1. 互斥

synchronized会起到互斥的效果,某个线程执行到某个对象的synchronized中,其他线程如果也执行到同一个对象的synchronized所包含的代码中就会阻塞等待。

public void increase() {
       synchronized (this) {//进入该代码块,相当于针对当前对象"加锁"
           count++;
       }//退出该代码块,相当于针对当前对象"解锁"
    }

synchronized用的锁是存在Java对象头里面,可以简单的理解为,每个对象在内存中存储时,都会有一块内存表示当前"锁定"的状态,相当于记录有没有人使用,如果当前是"无人"状态,那么就可以使用,使用时需要设为"有人"状态,如果当前是"有人"状态,那么其他人无法使用,只能排队。这个排队并不是真正意义上的按顺序来,在操作系统内部会维护一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上,就会阻塞等待,一直等待之前占有锁的线程解锁之后,由操作系统唤醒一个新的线程,再来获取锁,唤醒某个线程并不遵守先来后到的规则,比如A和B线程都在等待C线程释放锁,当C线程释放锁之后,虽然A线程先等待,但是A不一定先获取到锁,而是要和B竞争,谁先抢到就是谁的。

2. 可重入

synchronized所包含的代码块对于线程的来说是可重入的,不会出现自己把自己锁死的情况。所谓自己把自己锁死可以理解为针对同一个对象连续加锁多次,按照之前对锁的设定,第二次加锁的时候,就会阻塞等待,知道第一次的锁是释放,才能获取到第二个锁,但是释放第一个锁也是由该线程完成的,导致该线程就彻底躺平了,啥都干不了,就无法进行解锁的操作。这就是死锁。

    public void increase() {
        synchronized (this) {//加锁
            synchronized (this) {//加锁
                count++;
            }//解锁
        }//解锁
    }

在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.

如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.

解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

比如上述连续加锁的代码,第一次加锁的时候计数器加一,紧接着第二次又加锁,发现锁的持有者还是自己继续加一,然后就进行两次锁的释放,最终计算器为0时,才是真正的释放锁。

synchronized使用示例

synchronized 是对象锁本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具

体的对象来使用.

1. 直接修饰普通方法 锁的 Counter对象

对上述自增程序尝试使用synchronized加锁,两个线程同时访问的是increase()方法,所以对此方法加锁,实际上对某个对象加锁,该方法属于实例方法此锁的对象就是this。

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

运行结果:

2. 修饰静态方法: 锁的 Counter类的对象

示例代码

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

运行结果:

3. 修饰代码块:明确指定锁哪个对象
public class Counter {
    private int count = 0;
    public void increase() {
       synchronized (this) {
           count++;
       }
    }
    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    counter.increase();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    counter.increase();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:

再次分析变量自增的案例

当对increase()方法加锁之后,线程1进入该方法时会尝试着去获取锁,一旦获取到锁就会加锁(lock),当退出方法或者退出synchronized所包含的代码块时会释放锁(lock),在线程1持有锁期间,线程2只能干等着,无法进行自增操作,只能等待线程1释放锁,线程2才会进行自增操作。

注意:两个线程竞争同一把锁才会阻塞等待,如果是获取不同的锁,不会竞争。这就好比,两个男的同时追同一个妹子才会有竞争,否则不存在竞争。

volatile

volatile 修饰的变量, 能够保证 “内存可见性”.

代码在写入volatile修饰的变量的时候

  • 改变线程工作内存中volatile变量的副本的值
  • 将改边后的副本值从工作内存刷新到主内存

代码在读取volatile修饰的变量的时候

  • 从主内存中读取volatile变量的最新值到线程的工作空间
  • 从工作空间中读取volatile变量的副本

加上volatile,会强制读写内存,速度是慢了,但是数据的准确性提高了。

示例代码

import java.util.Scanner;
public class Counter2 {
    public int flags = 0;
    public static void main(String[] args) {
        Counter2 counter = new Counter2();
        Thread t1 = new Thread(() -> {
            while (counter.flags==0) {//该操作对于cpu太快了,所以就直接优化了,第一次读取到了寄存器中后面就没有再从内存中读取
            }
            System.out.println("循环结束");
        });
        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            counter.flags =  scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

运行结果:

当输入了1线程t1并没有退出,这显然是个bug,给flag加上volatile修饰就可以解决。

 public volatile int flags = 0;

运行结果:

volatile与synchronized

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

示例代码

public class Counter {
    private  volatile  int count = 0;
    public void increase() {
       count++:
    }
    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:

count的值小于预期值,并不能保证原子性。

相关文章
|
6月前
|
安全 Java 调度
【Java】JavaSE实现多线程
【Java】JavaSE实现多线程
86 1
|
Java 调度 开发者
【JavaSE专栏84】线程让步,一种线程调度的机制
【JavaSE专栏84】线程让步,一种线程调度的机制
106 0
|
Java 调度
【JavaSE专栏81】线程休眠,一种暂停线程执行的方法
【JavaSE专栏81】线程休眠,一种暂停线程执行的方法
189 0
|
缓存 安全 Java
【JavaSE专栏78】线程同步,控制多个线程之间的访问顺序和共享资源的安全性
【JavaSE专栏78】线程同步,控制多个线程之间的访问顺序和共享资源的安全性
|
安全 Java
【JavaSE专栏76】三态和五态,线程的不同状态:新建、运行、状态、阻塞、等待、计时等待状态
【JavaSE专栏76】三态和五态,线程的不同状态:新建、运行、状态、阻塞、等待、计时等待状态
102 0
|
安全 Java 开发者
【JavaSE专栏82】线程中断,发送一个中断信号给另一个线程,让它中断当前的执行
【JavaSE专栏82】线程中断,发送一个中断信号给另一个线程,让它中断当前的执行
|
4月前
|
算法 Java 调度
JavaSE—线程介绍(超详细!)
JavaSE—线程介绍(超详细!)
|
6月前
|
安全 算法 Java
JavaSE&多线程&线程池
JavaSE&多线程&线程池
170 7
|
6月前
|
Java API 调度
JavaSE基础精选-多线程
JavaSE基础精选-多线程
40 0
|
并行计算 安全 Java
【JavaSE专栏80】多线程通信,多个线程之间如何实现信息传递和同步?
【JavaSE专栏80】多线程通信,多个线程之间如何实现信息传递和同步?
127 0