快速认识多线程安全问题

认识线程不安全

分析以下代码的结果

class counter{
    int count = 0;
    void increase(){
         count++;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        counter counter =new counter();
        Thread thread = new Thread(()->{
            int i = 0;
            while (i < 5000){
                counter.increase();
                i++;
            }
        });
        Thread thread1 = new Thread(()->{
            int i = 0;
            while (i < 5000){
                counter.increase();
                i++;
            }
        });
        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(counter.count);
    }
}

结果到达预想是10000,并且每次的结果不是唯一,这个问题是因为线程不安全导致的.

线程安全的概念

想写出一个线程安全的确切定义是复杂的,但可以这样理解:

如果在多线程的运行环境执行的结果符合我们的预期,即在单线程环境执行的结果一样,就说明这个代码程序的安全的.

导致线程不安全的原因

 1. 多个线程修改一个变量(修改共同数据)

       刚刚上面提及的代码中,就涉及多个线程修改数据.两个线程都有对counter.count变量进行修改,

2.原子性

       什么是原子性: 我们可以这样子了解,我们把一段代码想象为去餐厅点菜吃饭并且只有一台机器进行点餐,此时每个线程就人,每个线程进行点菜,A线程先开始点餐,如果在A线程在点餐的过程中, B线程也可以参与点餐,此时就会导致A线程的点餐结果不同, 这就说明点餐是不具有原子性的.

如果将点餐的流程封装为一个”动作”,即需要等前一个线程完成点餐后,后一个线程才能进行这个”点餐动作”,这样就保证了”点餐动作”这段代码的原子性了.

在Java语句中一条Java语句不一定的原子性的,也不一定是一条指令

     比如i++; 列如(i=10) 这个指令其实是由三条指令组成的:

  1. 从内存把数据(10)读取到cup
  2. 进行对数据操作(加1)
  3. 把数据写回到内存中

原子性对多线程的重要性

当一个线程在对一个变量进行修改,此时另一个线程插入进来,就会干扰操作,导致结果错误.

3.可见性

  可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

线程之间的共享变量存在主内存 (Main Memory).

每一个线程都有自己的 "工作内存" (Workingd Memory) .

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

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

4. 代码重排序

 什么是代码重排序

有一段代码是这样写的

把家里的垃圾倒掉

整理家务

下楼买饮料

在代码执行时,jvm,cup指令集会对代码进行优化,比如,2->1->3,减少下楼次数,这种就是指令重排序.

指令重排序导致多线程不安全的原因:

编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价

解决线程不安全的问题

  1. 使用synchronized关键字

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待.

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

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

我们可以通过一个简单易懂的方式认识”加锁”,”解锁”是什么.

首先有请 , 小美 , 小莉, 小帅 , 丧彪 , 小沸 , 双面龟.

 小帅 , 丧彪 , 小沸 ,他们同时追求小莉, 由于小帅确实有点小帅,小莉就答应了小帅的追求,此时小帅就成功对小莉”加锁”, 此时丧彪 , 小沸想对小莉进行加锁,就无法加锁成功,只能阻塞等待(充当备胎), 但由于双面龟选择对小美进行加锁,由于加锁对象不同所以双面龟加锁成功,当小帅和小美闹矛盾分手后 ,进行了”解锁”,此时丧彪 , 小沸就可以解除阻塞等待重新对小莉上锁,即使是丧彪先于小沸追求小莉, 但是丧彪不一定就能获取到锁, 而是和小沸重新竞争, 并不遵守先来后到的规则.

       可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

理解”把自己锁死”

一个线程没有释放锁,又对这个锁进行加锁操作,由于锁没有被释放所以第二次加锁失败,开始阻塞等待.然而锁又无法释放,导致形成死锁.

由于java中 synchronized 是可重入锁,因此没有上面的问题.

代码示例

在下面的代码中increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前 对象加锁的.

在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释 放, 相当于连续加两次锁)

这个代码是完全没问题的. 因为 synchronized 是可重入锁.

class counter{
    int count = 0;
   synchronized void increase(){
         count++;
    }
    synchronized void increase2(){
       increase();
    }
}

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

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

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

6. synchronized使用方式

  Synchronized主要根据对象进行一系列操作,需要搭配具体的对象进行使用,

1.直接修饰普通方法: 锁的是 SynchronizedDemo对象

  1. class SynchronizdeDemo{
       
    public synchronized void methond1() {
            }
        }
    }
  2. 修饰静态方法:锁的是锁的 SynchronizedDemo类对象
  3. class SynchronizdeDemo{     public static synchronized void methond2(){     } }

两者对比

class SynchronizedDemo{
    public synchronized void methond() throws InterruptedException {
        while (true) {
            System.out.println("Hello world");
            Thread.sleep(1000);
            System.out.println("Hello java");
        }
    }
    public static synchronized void methond2() throws InterruptedException {
        while (true) {
            System.out.println("Hello world");
            Thread.sleep(1000);
            System.out.println("Hello java");
        }
    }
}

public class Thread1 {
    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo =new SynchronizedDemo();
        SynchronizedDemo synchronizedDemo1 = new SynchronizedDemo();
        Thread thread = new Thread(()->{
            try {
                synchronizedDemo.methond1();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread thread2 = new Thread(()->{
            try {
                synchronizedDemo.methond1();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        thread.start();
        thread2.start();
    }
}

修饰普通方法:相同对象的methrond方法,打印顺序是有规律的,而相反使用不同对象,打印顺序无法预测,两者说明synchronized修饰普通方法锁的是 SynchronizedDemo对象 

Thread thread2 = new Thread(()->{
            try {
                synchronizedDemo1.methond1();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

修饰静态方法: 由于锁的是SynchronizedDemo类对象,不管调用对象是否相同,输出顺序都一样

              Thread thread = new Thread(()->{
    try {
        synchronizedDemo.methond2();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});


Thread thread2 = new Thread(()->{
    try {
        synchronizedDemo1.methond2();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});

我们重点要理解: 两个线程竞争同一把锁, 才会产生阻塞等待, 两个线程分别尝试获取两                            把不同的锁, 不会产生竞争

Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据,

又没有任何加锁措施.

1.  ArrayList

2.  LinkedList

3.  HashMap

4.  TreeMap

5.  HashSet

6.  TreeSet

7.  StringBuilder  

但是还有一些是线程安全的. 使用了一些锁机制来控制.

  1. Vector
  2. HashTable
  3. Concurrent
  4. HashMap
  5. StringBuffer

valatile关键字

Volatile能保证内存可见性

  Volatile用于修饰变量,保证内存可见性

       判断下面代码结果

class SynchronizedDemo {
    volatile int flag = 0;
}

       public class Thread1 {
    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        Thread thread1 = new Thread(()->{
            while (synchronizedDemo.flag == 0){
                //什么也不做
            }
            System.out.println("线程执行结束");
        });
        Thread thread2 = new Thread(()->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个整数");
            synchronizedDemo.flag = scanner.nextInt();
        });
        thread1.start();
        thread2.start();
    }
}

当随便输入一个整数时,可以发现thread1线程仍没停止,说明while循环的判断条件仍为true,但flge的值确实已被我们修改,其实上述原因是内存可见性在搞鬼.

我们知道CPU的处理速度的非常快的,在短短的3秒时间 , flag==0 这条语句可能已经执行了上百亿次了 , 由于结果每次flag的值都为零,所以就暂时停止了从主内存读取flag到自己的工作内存中.因此当thread2对 flag 变量进行修改, 此时thread1感知不到 flag 的变化

使用volatile修饰变量

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

改变线程工作内存中volatile变量副本的值

将改变后的副本的值工作内存刷新到主内存

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

       从主内存中读取volatile变量的最新值到线程的工作内存中

      从工作内存中读取volatile变量的副本

使用synthronized也可以保证内存可见性

   在判断代码处加上

while (true){

   synchronized(synchronizedDemo){

         If(counter.flag != 0){

            break;

             }

       }

}

但需要注意:volatile不保证原子性,synchronized同时够保证原子性和内存可见

好了本次的学习分享就到这里了,如果本次分享对你有帮助的话,请点一个免费的赞支持一下作者哦.谢谢啦! 我们下次再见.

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值