《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的值小于预期值,并不能保证原子性。

相关文章
|
8月前
|
缓存 安全 Java
「Java面试」将线程安全讲的如此清新脱俗:你对线程安全性的理解
一位4年工作经验的小伙伴,被问到一个非常抽象的问题,说,谈谈你对线程安全性的理解。如果平时只是刷刷面试题的话,遇到这种问题可能不知道如何说起了,往往需要自己组织语言。另外,如果平时积累不够的话,也很难说出一些自己独特的见解来。
56 0
|
5月前
|
存储 缓存 Java
JavaSE基础篇:多线程
JavaSE基础篇:多线程
|
9月前
|
存储 容器
《JavaSE-第十七章》之LinkedList
《JavaSE-第十七章》之LinkedList
20489 34
|
10月前
|
存储 监控 算法
【JavaSE专栏18】用大白话讲解 Java 中的内存机制
【JavaSE专栏18】用大白话讲解 Java 中的内存机制
188 0
|
10月前
|
Java 调度
【JavaSE】Java基础语法(三十四):实现多线程
1. 简单了解多线程 是指从软件或者硬件上实现多个线程并发执行的技术。 具有多线程能力的计算机因有硬件支持而能够在同一时间执行多个线程,提升性能。
|
10月前
|
安全 算法 Java
【JavaSE】Java基础语法(三十八):并发工具类
1. Hashtable Hashtable出现的原因 : 在集合类中HashMap是比较常用的集合对象,但是HashMap是线程不安全的(多线程环境下可能会存在问题)。为了保证数据的安全性我们可以使用Hashtable,但是Hashtable的效率低下。
|
10月前
|
存储 Java
【JavaSE】Java基础语法(二十九):Map集合
1. Map集合概述和特点 Map集合概述
|
10月前
|
Java
【JavaSE】Java基础语法(二十二):包装类
1. 基本类型包装类 基本类型包装类的作用 将基本数据类型封装成对象的好处在于可以在对象中定义更多的功能方法操作该数据常用的操作之一:用于基本数据类型与字符串之间的转换 基本类型对应的包装类
|
10月前
|
安全 Java 调度
【JavaSE】Java基础语法(三十五):多线程实战(1)
1. 多线程入门 1.1 多线程相关概念 并发与并行 并行:在同一时刻,有多个任务在多个CPU上同时执行。 并发:在同一时刻,有多个任务在单个CPU上交替执行。 进程与线程 进程:就是操作系统中正在运行的一个应用程序。 线程:就是应用程序中做的事情。比如:360软件中的杀毒,扫描木马,清理垃圾。
|
10月前
|
缓存 算法 安全
【JavaSE】Java基础语法(三十五):多线程实战(2)
3. 线程死锁 概述 死锁是一种少见的,而且难于调试的错误,在两个线程对两个同步锁对象具有循环依赖时,就会大概率的出现死锁。我们要避免死锁的产生。否则一旦死锁,除了重启没有其他办法的. 产生条件