线程安全是什么问题?如何引起?死锁是啥?如何解决?

目录

一、什么是线程不安全?

二、如何引起的线程安全?怎么解决?

1)CPU调度执行是随机的,抢占式执行(根本原因,硬件层面咱们无法干预)

2)多个线程,对同一变量进行修改

3)修改操作中, 不是“原子”的(重点)

死锁是啥,怎么引起的?  (重点)

4)内存可见性

5)指令重排序 

 总结-保证线程安全的思路


一、什么是线程不安全?

 线程不安全:

是多个线程并发执行,而产生的结果和我们预期的不相同,这种bug是由多线程引起的,所以我们叫线程安全问题,也就是线程不安全。

 比如看下列代码:

public class demo8 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
           for(int i=0;i<50000;i++){
               count++;
           }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+count);
    }
}

我们预期是t1线程50000次,t2线程50000次,加起来一共100000次。这个是我们预期的结果。但是真实的情况如下: 

是79506次,而且每一次的结果都不一样,这样和预期不符合,就是线程安全问题。

 为什么会出现这样的情况呢?如果是单线程会如此吗?请看下文:

二、如何引起的线程安全?怎么解决?

 上面那个count的例子来解释:

我们知道CPU把线程调度上去执行,最小单位是指令,我们count++其实是3个指令:

1)把值从内存中读取到CPU的寄存器上(load)

2)在寄存器上把count+1(add)

3)把寄存器加完之后的值传到内存中(save)

当然上面那样,是我们最理想,也是我们想要的状态,但是在多线程的加持下,因为CPU的调度是抢占式的,是随机的,我们并不能操控,可能刚执行了一半就给调走了。我们基本上很难遇到上面的情况,可能会出现以下的情况(简单列举了几种):

最小单位是指令,CPU不会执行半个指令,会给你执行完一个,但是count++有3个指令,我也不知道CPU什么时候调度走,如何给别人执行,这样就会造成bug,也就是我们说的线程安全问题!

既然我们明白了什么是线程安全,下面是五种会出现线程安全问题的原因,我们可以思考下面五种原因,从而去解决线程安全问题,或者去避免写出错误的代码:

1)CPU调度执行是随机的,抢占式执行(根本原因,硬件层面咱们无法干预

CPU的调度执行具体的可以参考我往期的博客,这里就不赘述了。

2)多个线程,对同一变量进行修改

这个行不行,具体要看业务需求,但是对于Java来说,这并不是一个很好的解决方法,业务还有更好的方法。

如果是单个线程,对一变量修改,那就没有关系,不会出线程安全的BUG。

3)修改操作中, 不是“原子”的(重点)

 这个来说,难道就是把类似像count++这类不是原子的,变为原子的吗?答:是的。

那咱们要怎么变啊?一条代码写多条?

答:给它加锁! (相当于一夫一妻制,锁上其他线程就进不来了,只能等他释放锁,其他线程才能进去)

 如何使用锁?语法是啥?

关键字:synchronized

Object locker=new Object();
synchronized(locker){....}

 如图

也就是: 既然大家都是串行化了,那效率会不会大大降低呢?

答:不会,因为这只是小部分需要加锁,其他代码都是不需要的。也就少了那么一点点,对于计算机来说,这些都是小case

注意事项:

1. 这个得都是同一个锁对象(才会发生锁竞争,就是堵塞),也就是传参(我这写为locker),如果一个是locker1一个是locker2,相当于2个厕所,互不影响,都能上厕所。

2.传参(locker)只要不是int,char这种简单类型,其他object子类都行,甚至写class对象都行

3.想要相互不影响的线程必须都得加锁,就是t1和t2的count都得加锁,相当于限制t1和t2只能上这个厕所,有一个人就得排队。如果只是一个加锁,t1打包好的3条指令很大程度会插入t2那3条指令中间。(如上图)

4.如果是多个线程一起加锁,t1执行完了,t2和t3谁先执行呢?是随机执行,抢占式调度。

5.synchronized还可以修饰方法

6.还可以修饰静态的方法,但是要用类对象做锁对象

7.用锁要考虑清楚,不然到时候某个线程堵塞了,什么时候恢复就不知道了

8.可重入锁,可连续加锁两次/多次,遇到加锁的会放行,不加第二次。(请看下面关于死锁的内容)

死锁是啥,怎么引起的?  (重点)

Ⅰ:针对一把锁连续加锁两次

如上图,如果连续加两次锁,那么就会构成一个死循环,也就成了死锁。

但是要注意的是,在Java中synchronized可以自动判断,如果有加锁过了,是同一个锁对象的话,则在下次加锁的时候会放行,这也叫可重入锁。不会真正的报错,抛异常。 

Ⅱ:两个线程两把锁

1)线程1在对A加锁,线程2对B加锁,

2)线程1在不释放A的情况下,要对B加锁;同时线程2在不释放B的情况下,对A加锁。

依照上面的情况,势必会僵持住,造成死循环,这也是死锁。

比如:

我和朋友在吃饺子,有醋有辣椒可以蘸饺子,我想拿醋放了点,朋友拿了辣椒放了点。这时候我又想要辣椒了,他又想用醋。这时他说你把醋给我,我再把辣椒给你。我说你先给我辣椒,我在给你醋。这时候我们就会僵持不下(当然这件事情可以商量,但是在程序中,代码的执行是死板的,就会变成死锁)

上述代码就是个例子。t1不释放locker1又要locker2,t2不释放locker2又要locker1,两个线程谁也不服谁。

Ⅲ:M个线程M把锁

和2个线程2把锁类似。哲学家都在等右手边的筷子,才能拿起筷子吃面条,但是没人放下右手边的筷子(因为他们也在等他们右手边的筷子才能吃完,吃完才能放下来)都僵持住了。

死锁的四个必要条件:

(1)锁是互斥的(锁的基本特性)

(2)锁是不可被抢占的(锁的基本特性)

比如线程1加锁了,没释放的前提下,线程2不能去抢

(3)请求与保持(代码结构)

比如线程1加锁A了,没释放A的前提下(在保持着),去加锁B(请求)

(4)循环等待/环路等待/循环依赖

多个线程获取锁,就可能会陷入死循环

由于1和2是锁的基本特性,所以我们无法避免。但是对于3来说,有些时候,我们是需要写成3那样的结构的。所以就要看4,只要4不成立,就不会产生死锁。 

如何用4解决呢?

答:约定加锁的顺序,编号小的先加锁,如何编号大的再加锁

比如上面的代码,我们先让t1加锁locker1和locker2,再让t2加锁locker1和locker2。这样按顺序加锁就不会产生死锁了。

4)内存可见性

请看下图:

写了2个线程,想要t2输入值修改了n之后,t1的while中结束,因为n不为0了。但是我们输入后会发现并不会结束。为什么?这就是因为内存可见性  

内存可见性同时也是bug,也是线程安全问题。但这并不是我们代码书写,而是JVM自己的问题,是JVM自己优化,优化出了问题。

这里的快慢并不是绝对速度的快慢,而是相对速度的快慢:

内存不可见性原因及原理:

JVM发现执行这个代码的时候,发现每次循环的过程中,执行1)操作,开销非常大,而且每次执行1)的结果都是一样的。

并且JVM根本没有意识到,用户未来会修改这个n,于是JVM就做了一个大胆的操作,直接把1)这个操作优化了。

也就是每次循环,不会重新读取内存中的数据,而是直接读取寄存器/cache中的数据。当做了这个操作后,循环的开销也就大大降低了,

但是t2对于在内存中的修改,t1是感知不到的,所以也就是t1的在“内存不可见性”。

为啥JVM要优化代码呢?

答:因为程序员的编程水平是参差不齐的,为了减少程序员的差距,降低程序员的门槛,JVM就会优化代码,让厉害的程序员和一般的程序员不会差距太明显。 

如何解决内存可见性问题?

加入关键字:volatile,也就是在n那里加入volatile

 在n上面加个volatile就能解决这个问题,volatile(易变的),也就是告诉JVM这个n是易变的,让JVM不要优化这个代码,让它在内存的修改能被读取到。 t1也就不会直接在寄存器上读取了。

5)指令重排序 

 指令重排序也是JVM自己优化出来的一个bug

比如你妈妈让你去菜单给你个清单,如下 

如果你老老实实按清单的买,那就太慢了,而且要走很久,如果你调整一下顺序,则就能快很多,那么JVM也是这么想的,把指令重新调整一下,提高效率。这就叫做指令重排序。

这个是一个单例模式中懒汉模式的代码,一个程序只需要一个对象,所以如果没有创建,我可以创建出来一个,如果有了,我返回这个对象就行了。两层if是不一样的功能,第一层主要是为了不频繁加锁消耗资源(因为如果instance没被new出来,则需要加锁给new出instance,不然可能会被其他线程打断)。第二层是实现唯一的实例,new过就直接return就好了。

为什么这个代码也算线程不安全呢?

 我们的JVM在执行new操作的时候,会把2和3调整一下

 既然这个问题这么可怕,我们怎么解决这个问题呢?

答:用关键字volatile。

只要我们加上volatile,就告诉JVM这个instance是易变的,让JVM不要去优化它,这样就可以解决指令重排序的问题了。

 总结-保证线程安全的思路

1. 使⽤没有共享资源的模型

2. 适⽤共享资源只读,不写的模型

        a. 不需要写共享资源的模型

        b. 使⽤不可变对象

3. 直⾯线程安全(重点)

        a. 保证原⼦性

        b. 保证顺序性

        c. 保证可⻅性

  • 16
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
`ConcurrentHashMap` 是 Java 标准库中的一种高效且线程安全的哈希表实现,设计目的是允许多个线程同时对其进行读写操作。它是 `java.util.concurrent` 包下的一个重要组件,由Doug Lea等人创建,为了解决传统 HashMap 在并发环境中的竞态条件问题。 `ConcurrentHashMap` 的线程安全性主要源于以下几个方面: 1. 分区与锁分离:`ConcurrentHashMap` 将元素分布在多个不同的分区(bucket),每个分区有自己的锁,这样即使多个线程试图访问不同分区,也可以并行进行。只有当某一个分区被修改时,才会锁定对应的锁,减少全局阻塞。 2. 写操作的并发控制:写操作会先加互斥锁,然后将所有涉及的数据结构都重新散列,这样即使在写的过程中其他线程尝试读取或写入,也不会有问题,因为新旧版本之间有一个短暂的过渡期。 3. 并发读操作:读操作是无锁的,或者说弱锁,大部分情况下不需要锁定整个表。通过分段锁(Segment Locks)、读-写锁(ReadWriteLock)以及 CAS(Compare and Swap)等技术,支持高并发的读操作。 4. 避免死锁:`ConcurrentHashMap` 使用自旋锁(轻量级锁)来避免长时间占用 CPU 导致的死锁,对于短命的锁请求,它会选择不断循环检测直到获取锁。 因此,`ConcurrentHashMap` 在设计上确保了多个线程能并行地进行读写操作,极大地提高了并发性能,使得它非常适合于高并发场景下使用。 相关问题: 1. ConcurrentHashMap 如何处理读和写的并发冲突? 2. 为什么要使用自旋锁而不是立即挂起等待锁? 3. 当发生冲突时,`ConcurrentHashMap` 是如何保证数据一致性的?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值