3

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

 1 year ago
source link: https://blog.51cto.com/u_15454299/6990426
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在你立足处深挖下去,就会有泉水涌出!别管蒙昧者们叫嚷:“下边永远是地狱!”

博客主页:KC老衲爱尼姑的博客主页

 博主的github,平常所写代码皆在于此

共勉:talk is cheap, show me the code

作者是爪哇岛的新手,水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!


  • 共享受限资源
  • 什么是线程安全问题?
  • 存在线程安全问题
  • 线程不安全的原因
  • 内存可见性
  • 指令重排序
  • synchronized的特性
  • 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);
    }
}

运行结果:

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

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

线程不安全的原因

  1. 多个线程同时修改同一个共享数据,如上述代码修改堆上的count
  2. 操作系统对于线程的调度是抢占式的
  3. 修改操作不是原子的
  4. 内存可见性问题
  5. 指令重排序

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

内存可见性

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

《JavaSE-第二十二章》之线程安全问题_加锁_02
  • 线程之间的共享变量存在 主内存 (Main Memory),实际上是内存。
  • 每一个线程都有自己的 “工作内存” (Working Memory) ,这里的内存指的是CPU中 的寄存器或者高速缓存。
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.。
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存。

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

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

《JavaSE-第二十二章》之线程安全问题_自增_03

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

《JavaSE-第二十二章》之线程安全问题_加锁_04

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

指令重排序

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

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

  1. 从内存把数据读到CPU中的寄存器,该操作记作load
  2. 对数据完成自增,.该操作记作add
  3. 把数据写会内存,该操作记作save

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

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

《JavaSE-第二十二章》之线程安全问题_自增_05

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

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

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

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

synchronized的特性

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);
    }
}

运行结果:

《JavaSE-第二十二章》之线程安全问题_i++_08
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);
    }
}

运行结果:

《JavaSE-第二十二章》之线程安全问题_安全_09
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);
    }
}

运行结果:

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

再次分析变量自增的案例

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

《JavaSE-第二十二章》之线程安全问题_自增_11

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

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();
    }
}

运行结果:

《JavaSE-第二十二章》之线程安全问题_加锁_12

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

public volatile int flags = 0;

运行结果:

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

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);
    }
}

运行结果:

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK