线程安全问题

线程安全问题

1. 观察线程不安全情况

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

    public static void main(String[] args) {
         Counter counter = new Counter();
         Thread t1 = new Thread(()->{
             for (int i = 0; i < 50000; i++) {
                 counter.add();
             }
         });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
           e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}

在这里插入图片描述

上述代码中两线程分别对count进行50000次自增操作,但是结果并不是100000,结果也并不是固定为63079而是每次运行结果都不一样,没有达到预想的结果,这就是线程不安全。

2.线程安全的概念

通俗的说,代码能达到预期结果线程就是安全的,没到预期结果就不安全。

3.线程为什么不安全?

上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
此时这个 counter.count 是一个多个线程都能访问到的 “共享数据”。

线程对count的操作流程如下图:
在这里插入图片描述
上图可看出线程对count自增操作有三个步骤:

1 load操作,将内存中count的值写入进程。
2 add操作,将写入的值进行自增操作。
3 save操作,将自增后count的值重新写入内存

每个进程都有着三个操作,因为线程是抢占式执行的,所以着三个操作也在抢占式执行,三个操作都不固定,就有可能发生以下的情况:
在这里插入图片描述
由上图可以看出进程t1和t2分别对count进行了一次自增操作但是内存中的count只从1变为了2,这就是因为共享内存使进程t1和t2写入了同一个count值导致最终只改变了一次最终值。

以上只是线程不安全的其中一个情况还有很多种情况使count不能以预想的结果输出,但是所有情况都是因为共享内存引起的。

3.1线程不安全的原因

  1. 抢占式执行(线程不安全的源头)。
  2. 多个线程修改一个变量。
  3. 修改啊操作不是原子的。
  4. 内存可见性,引起线程不安全。
  5. 指令重排序,引起线程不安全

其中四,五条原因和以上对count的自增例子无关,由其他场景引起。

4线程的原子性

4.1什么是原子性

原子性是指一个操作或一组操作要么全部执行成功,要么全部不执行,不会出现部分执行的情况。

例如:一个公共厕所,当厕所里面有人时,门是上锁的外面的人进不去,只有当里面的人出来后,外面的人才可以进入,不会出现里面的人还在上厕所,外面又进去一个人也同时上厕所的情况,这就是原子性。

4.2不保证原子性会给多线程带来什么问题

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

这个和线程的抢占式执行密切相关,如果线程不是抢占式执行的就算不保证原子性也没有问题。

5.synchronized 关键字

5.1synchronized的特性

互斥

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

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
    在这里插入图片描述

当一个线程先上了锁之后,其他线程就只能等待这个线程释放。

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。

可重入

一个线程针对同一个对象连续加锁两次, 如果没问题, 就是可重入的, 如果有问题, 就是不可重入的。

死锁情况:

首次加锁,成功
lock()
第二次加锁,锁已经被占用,阻塞等待
lock()
只有等第一次的锁被释放,第二次才能加锁成功,但是第一个锁也是由同一个线程完成的,此时就会出现死锁的情况。

然而synchronized是可重入的锁,就没有上面的死锁情况

5.2Java 标准库中的线程安全类

Java 标准库中很多线程都是线程不安全的, 这些类会涉及到多线程修改共享数据, 但是又没有加锁的措施。

  • ArrayList
  • LinkedList
  • HashMap
  • TheeMap
  • HashSet
  • TreeSet
  • StringBuilder

有些类是线程安全的, 强行加锁了

  • Vector(不推荐用)
  • HashTable(不推荐用)
  • ConcurrentHashMap
  • StringBuffer

还有一种虽然没有加锁, 但是不涉及修改的类, 也是线程安全的

  • String

上述描述可以看出常用的集合都没有加锁,线程并不安全,而强行加锁的都不推荐使用或者不常用,之所以这样是因为,内置synchronized 相对来说更安全一点, 但是安全带来的另一种影响是性能上的损耗, 要在适当的场景中合理的使用。

6死锁

6.1死锁的概念

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

6.2 死锁的情况

  1. 一个线程一把锁,将同一把锁第二次加在线程身上时,如果锁是可重入锁就没事,是不可重入锁就会出现死锁的情况。
  2. 两个线程两把锁,即便是可重入锁也会锁死。
public class ThreadDemo14 {
    public static void main(String[] args){
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1获取了两把锁");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1){
                    System.out.println("t2获取了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
这里可以看到,以上代码是没有任何输出的,原因就在于,t1线程在获取锁1后尝试获取锁2,而t2线程在获取锁2后尝试获取锁1,两个线程都在等对方释放锁,这样就造成死锁的情况。

  1. 多个线程多把锁
    这里引入一个哲学家就餐问题来进行理解:
    在这里插入图片描述
    如上图:五个哲学家围在一张桌子前,桌子中间放着鸡腿,以及编号为1-5的五根筷子,只有哲学家同时拿起两根筷子才可以吃到鸡腿。

这时每个哲学家都是一个线程,他们都有两中状态:

  1. 拿不起两根筷子(筷子被其他人占用),什么都不做。(线程阻塞等待)
  2. 拿起了两根筷子,吃鸡腿。(线程获取锁并执行)

这时就会有一种极端的情况发生:
在这里插入图片描述
如上图所示,五位哲学家同时拿起了左手边的筷子,这时候没有一位哲学家同时拥有两根筷子,所有哲学家都在等待右手边的人吃完释放筷子,这时候就进入了循环等待的情况,导致进程直接僵住了,造成死锁。

6.3如何避免死锁

产生死锁的四个必要条件:

  1. 互斥使用:当一个锁被占用时,其他线程不能再获取这把锁,即当线程想要获取已经被占用的锁时就会进入阻塞等待状态,等待锁的释放。
  2. 不可抢占:当一个锁被占用时,其他想要获取这把锁的线程不能强制获取,只有等待锁被释放才可以获取到。
  3. 请求和保持:当一个线程已经获取了一把锁,同时尝试去获取另一把锁的时候,第一把锁不会因为第二把锁的请求而被释放。
  4. 循环等待:三个线程A,B,C ,分别获取到锁A,B,C ,这时线程A要获取锁B,线程B要获取锁C,线程C要获取锁A,这样就形成了一个等待环路,所有线程都在等待对方释放锁。

6.4 打破死锁

java中以上四个死锁必要条件中前三条是synchronized的基本特性不可改变,所以打破死锁就只能从第四题循环等待入手。

继续引用上面的哲学家就餐问题:

我们给哲学家们制定一个规则,每人只能先拿起左右手边编号较小的筷子,再拿起编号较大的筷子,这样就会变成以下这样:

在这里插入图片描述
这样一来,有两个哲学家都要拿一号筷子,这样就会有一个哲学家拿不到,这时根据规则只能拿小编号筷子,这个没拿到筷子的哲学家就要等待一号筷子被释放,这时5号筷子会剩余,再根据规则,从5号筷子开始拿4,5号筷子的哲学家吃完放下筷子,这样拿3号筷子的哲学家就能拿起4号筷子…一直循环到拿一号筷子的哲学家放下筷子,这样最初没拿到1号筷子的哲学家就可以拿起1,5号筷子最后就餐。

这样修改以上死锁的代码:

public class ThreadDemo14 {
    public static void main(String[] args){
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1获取了两把锁");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t2获取了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
让两个线程都先获取锁1,再获取锁2,这样就会打破死锁。

7. volatile 关键字

被volatile关键字修饰的变量,能够保证其内存的可见性。加上volatile后,强制读写内存,速度会变慢,但是数据会更准确。
当一个变量被volatile修饰的时候:

  • 修改被修饰变量时:
  1. 改变线程工作内存中变量的值。
  2. 将改变后变量的值从工作内存中刷新到主内存。
  • 读取volatile修饰的变量的时候:
  1. 从主内存中读取volatile修饰的变量的最新值到线程的工作内存中。
  2. 从工作内存中读取到volatile变量的值。

如下代码对比:
不加volatile的情况:

class MyCounter{
     public int flag = 0;

}
public class ThreadDemo15 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while (myCounter.flag == 0){
               
            }
            System.out.println("t1循环");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
加上volatile的情况:

class MyCounter{
    volatile public int flag = 0;

}
public class ThreadDemo15 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while (myCounter.flag == 0){
                
            }
            System.out.println("t1循环");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
从上面两个代码实例的运行结果可以看出,在没有volatile的时候,线程2里面修改了flag的值后并没有使线程1的循环终结,代码一直在循环,在加上volatile关键字后线程1的循环可以正常终止。

8. wait 和 notify

在实际代码运行中,线程之间时抢占式执行的,但是有时候我们的代码需要控制某些线程的先后顺序,这时候可以用wait 和 notify.

wait:让线程进入到等待状态。
notify:唤醒当前对象上等待的线程。

8.1 wait()方法

wait 对代码起到的影响:

  1. 使执行到wait()的线程进入等待状态。
  2. 释放当前线程的锁。
  3. 如果在等待中的线程被notify唤醒,线程会重新获取这把锁。

wait要搭配synchronized使用,否则的话wait会直接抛出异常

wait结束等待的条件:

  1. 其他现场调用了被wait对象的notify方法。
  2. wait等待时间超时(wait有timeout参数版本,可以指定等待时间)
  3. 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出异常。

8.2 notify()方法

与wait对应nofity是唤醒等待中的线程,但是使用nofity时要注意:

  1. nofity要在wait同一个代码块中使用,使等待中的线程被唤醒,重新获取该对象的锁。
  2. 如果有多个线程处于等待状态,nofity会随机唤醒一个,不能指定唤醒。

8.3 wait和notify的代码实例

wait()的使用:

public class ThreadDemo16 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait 之前");
            object.wait();
            System.out.println("wait 之后");
        }
    }
}

在这里插入图片描述
以上代码与运行 结果可看出,当线程调用wait()方法后便没有了动静,只打印出了“wait之前”的日志,“wait之后”的日志并没有打印出来。
notify()方法的使用:

public class ThreadDemo17 {
    public static void main(String[] args) {
        Object object = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("t1wait:之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println("t1wait:之后");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2notify:之前");

            synchronized (object) {
                object.notify();
            }

            System.out.println("t2notify:之后");
        });
        t1.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t2.start();
    }
}

在这里插入图片描述
上述代码中线程t1调用wait()方法打印wait的日志,线程t2调用notify()方法打印notify日志,可以从打印结果看出,t1线程wait之后便没有了动静,开始执行t2线程,当notify()方法被t2调用后,t1线程的wait状态被打破。

8.4 notifyAll()方法

与notify()方法类似不同的是notify()方法在有多个线程同时等待时会随机唤醒一个,而notifyAll()方法在多个线程同时等待时会将等待的线程同时全部唤醒。

public class ThreadDemo17 {
    public static void main(String[] args) {
        Object object = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("t1wait:之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println("t1wait:之后");
        });
        Thread t3 = new Thread(() -> {
            System.out.println("t3wait:之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println("t3wait:之后");
        });
        Thread t4 = new Thread(() -> {
            System.out.println("t4wait:之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println("t4wait:之后");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2notifyAll:之前");

            synchronized (object) {
                object.notifyAll();
            }

            System.out.println("t2notifyAll:之后");
        });
        t1.start();
        t3.start();
        t4.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t2.start();
    }

在这里插入图片描述
代码大致和notigy中一致,只是多加了两个和t1一模一样的线程t3和t4同时将t2线程的notify改为notifyAll这是就出现了以上的运行结果,线程t1,t3,t4走到wait时全部进入等待状态,在t2调用notifyAll后,三个线程全部被唤醒,开始抢占式执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值