线程安全和锁

一.线程安全问题

1.问题引入

        两个线程计算count,按照常理计算出的count应该为100000,但是答案永远小于100000,这是因为多线程引发的BUG,t1和t2并发执行导致的。

但是t1和t2串行执行就可没有问题。

2.详细解释问题

(1)文字解释

在线程安全问题引入中,写的代码count++操作,从CPU的视角看来其实有是三个指令:

1)把内存中的数据,读取到CPU寄存器当中。

2)把CPU寄存器里的数据+1。

3)把寄存器的值写回内存。

        CPU在调度执行过程中,可能会把线程给切换走也就是所谓的抢占式执行,随机调度。又因为指令是CPU执行的基本单位,如果要调度的话,应该先把当前指令执行完毕,再进行调度。

        但是由于count++是三个指令,可能会出现CPU执行了其中的1个指令或者2个指令或者3个指令后(上述执行指令个数是随机的,无法预测),便调度走了,切换其他线程中的指令了。

(2)图文解释

load是第一条指令,add是第二条指令,save是第三条指令

        这是一个t1中进行count++的三条指令的执行方式,不管是哪个线程里的指令,每个指令都是一样的执行方法,之所以count最后的值不对是因为,其中CPU执行指令时,执行指令的顺序不对,执行的指令不对,就造成了count++最后的答案不对(正确执行的执行顺序只有上述两张图)

        以下的图片中的指令顺序都是错误的,算出的答案都是错误的。简单地说,这里距离第一张图的第一个执行顺序:当t2执行load的时候执行的是把内存的数据存储到CPU寄存器当中,此时count的值为0,此时执行第二条指令,也就是t1的load指令,重复将count的值存入到CPU寄存器中,只是这两个寄存器是不同的,一个是t1一个是t2的。

        此时CPU执行第三条指令,add,在t1的CPU中进行数据加1操作,此时t1数据中的count为1,再执行第四条指令,save:将t1寄存器中的值写回内存,此时内存上的count为1。

        此时CPU再执行第五条指令,add,和上述t1相同,不再赘述,此时执行最后一条指令save,此时t2寄存器中的count为1,并且内存中的count也为1,但是save的操作是将寄存器中的值写回内存,则将t2寄存器中的值写回内存中,此时内存的count还是为1

        通过上述的过程讲解,就能知道上述实际count+1操作只执行了一次,所以就造成了最后count的结果是错误的。

还有一种情况是应该count+3,但是count+1:

3.线程安全的问题

1)线程在操作系统中,随机调度,抢占式执行(根本原因)

2)多个线程,同时修改同一个变量

3)修改操作,不是原子的

4)内存可见性问题

5)指令重排序问题

二.锁

1.锁的简单介绍

        简单地说,一个人上厕所锁门,只有当这个人上完厕所解锁后,下一个人才能进入。类比,问题引入中的count++的3个指令操作,加锁后,只有等count++的3个指令完成,才能进入下一个线程中,就保证了线程的安全。

2.synchronized关键字

(1)锁对象

        在Java中通过synchronized来进行加锁操作,synchronized的括号不是的并不是“参数”,而是一个锁对象,这个对象一般都是直接创建Object的对象。

        进入synchronized代码块就会对这个线程进行加锁,出了synchronized的代码块就会解锁。对加锁操作内部执行进行图文解释:

        还需要注意的是,两个锁的锁对象要相同,如果锁对象不相同,相当于没加锁,毕竟两个线程中一个线程执行要阻塞另一个线程执行是因为这个对象已经被上锁了,另一个线程没办法进入。如果两个锁对象不同的话,则会导致,大家互相上各自的锁,互不相干,则还是会引发线程安全问题。

        锁必须加在两个会发生随机调度执行的线程上,不能一个线程加锁而另一个线程不加锁,因为,一个A线程加锁,另一个B线程不加锁,则当A加锁的线程正在进行时,无法干预到未加锁的B线程,则没办法对B线程进行阻塞。

(2)修饰方法

静态方法中synchronized不再是this:

三.死锁问题

1.多个synchronized锁

        当进行线程1时,执行counteradd方法时,进入第一个锁,进入方法后,又进入一个锁(这两个锁的对象都是counter),但是此时已经进入一个锁了,应该是阻塞状态,再未解锁的情况下则无法进入另一个锁种,此时代码被卡住,这个情况就被称为死锁,但是Java中的synchronized做了特殊处理(可重入锁),解决了死锁。

Java中的解决方案

        当线程中已经被加锁后,再进行多次加锁,则进入第一个锁时,是真的进行加锁,进入后面的锁不是真的加锁,也就意味着不需要解锁,只需要解第一个锁,但需要找到第一个锁,解锁的位置,这就是可重入锁,也解决了死锁的问题。

        加锁和解锁时通过{}来决定的,进入{ 为加锁,出} 则为解锁,这里就通过计数器进行解决,当执行到{ 时进行计数+1,当执行到} 时,计数-1,当计数的数目为0时,就是真正进行解锁操作的时候,这找到第一个锁,Java中synchronized就是可重入锁。。

2.两个线程两把锁

        线程1先针对A加锁,线程2现先针对B加锁,线程1不释放锁A的情况下,再针对B加锁,同时,线程2不释放B的情况下针对A加锁。

3.N个线程,M个锁

        这个死锁是关于一个问题的:一张桌子上有五个人,有五根筷子,中间有一碗面,每个人如果要吃面就必须拿上两根筷子, 当遇到一种极端情况时,每个人都拿一根筷子,则每个人都有一根筷子,但是都少一根筷子才能吃面,但是没有一个人愿意放下拿到的筷子,此时也没办法从别人手上拿到筷子,这就造成了死锁问题。

4.产生死锁的必要条件

(1)锁是互斥的

(2)锁是不可被抢占的(线程1拿到锁A,如果线程1不主动释放锁A,线程2无法将锁A抢过来)

        (1)和(2)都是对于synchronized这样的锁,互斥和不可抢占,都是基本特性,程序员无法干预,除非自己实现一个锁,可以在互斥和不可抢占上做一些设计

(3)请求和保持(线程1拿到锁A后,不释放锁A的时候,再拿锁B)

        解决方法:如果先释放A再拿B就不会有问题,但是也不是所有的情况,都能用这个方案解决的,有些代码里,可能就需要写成请求和保持的方式。

(4)循环等待/环路等待/循环依赖(多个线程中获取锁的过程,存在循环等待)

        解决方法:假设代码按照请求和保持的方式,获取到N个锁,此时给锁编号1,2,3,...,N,约定所有线程加锁时,都必须按照一定的顺序进行加锁(比如,必须现针对编号小的锁加锁,后针对编号大的加锁)

5.内存可见性问题

        当输入了1,使n的值为1,按照常理来说,此时t1线程中的循环应该停止了,但是程序此时并没有停止,jconsole中也显示的正常,此时这种情况同样是线程安全问题。

        上述问题就是内存可见性问题,引起内存可见性问题的原因是:在t1线程中,while循环的条件是判断n是否为0,那么每次循环时,都需要对n进行判断,对于n进行判定的操作是分为两步,第一步是从内存读取数据到寄存器,第二步通过类似于cmp指令,比较寄存器和0的值。

        在读取内存数据和比较值的情况下,从内存读取数据到寄存器中的速度很慢,开销也很大,则JVM在执行代码时,会将第一步读取数据优化掉,只有第二步,是因为JVM执行的时候,多次进行第一步后的结果都是一样的,也因为第一步操作比第二步操作慢,开销大,这也导致了JVM优化后,并没有考虑之后程序员会对数据n进行修改。

        JVM优化后,直接读取寄存器/cache中的值(缓存的结果),当用户改了n的值时,内存中已经改变了,但是线程t1每次循环不会真的读取内存中的数据,则线程t1无法感知n的变化。

        所以n的值对于线程t1来说,是无法感知也是不可见的,这就引起了程序的BUG,也就造成了死锁问题:内存可见性问题。

内存可见性问题的另一种解释:

解决方法

 1.使用sleep

        sleep加入后,会取消上述JVM优化读取内存数据到寄存器中的步骤,而再次进行读取内存数据到寄存器中,因为和读数据的内存相比,sleep的开销更大,远远超过了读内存的开销。

2.volatile

        volatile用来修饰变量,告诉编译器,这个变量是易变的变量。引入了volatile后,编译器生成代码时,会给读取操作生成一个特殊的指令,被称为内存屏障,后续JVM执行到这些特殊指令,就不会进行优化了。

注意事项

 需要注意的是volatile只能解决内存可见性问题,不能对解决原子性问题:

6.wait(等待)/notify(通知)

        简单地说,当需要控制多线程之间执行的逻辑顺序,可以使用wait和notify。先让后执行的线程使用wait;先执行的线程执行完一些逻辑后,通过notify唤醒对应的wait。

        wait和notify是Object提供的方法,任意的Object对象都可以使用wait和notify。

(1)wait/notify使用

        需要注意的是,使用wait和notify时,需要都进行加(同一个)锁,不然编译器会报错,此处的monitor是锁的意思(图二);wait和notify的Object对象要一致;在wait中,是释放锁和等待的两个操作一起执行,不能分开执行,如果分开执行,当另一个线程很快通知时,该线程可能还未开始等待。

        wait一共做了三件事,释放锁;进入阻塞等待,准备接受通知;收到通知后,唤醒,重新尝试获取锁。

(2)wait和sleep区别

        wait当没有传递参数的时候,也是默认的死等,当传入了时间,wait达到了最大等待时间,还没被notify通知的话,就直接执行后面的代码。

        但是wait的目的是为了提前唤醒线程,sleep是固定时间等待,不涉及唤醒线程操作。即使sleep可以被Interrupt唤醒,但是Interrupt的操作是终止线程,而不是唤醒线程的意思。

        wait还必须和synchronized搭配使用,wait还会先释放锁,同时进行等待,sleep和锁无关,如果不加锁,sleep操作也是正常等待,如果sleep操作加了锁,也不会释放锁,会带着锁一起等待,此时其他线程还无法拿到锁。

(3)notify

        当notify唤醒多个wait的线程时,唤醒的线程是随机的,即使多次尝试,都唤醒的t1线程,但是也无法说先唤醒t1线程。

public class Demo24 {
    private static Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("t1 wait begin");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1 wait end");
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("t1 wait begin");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1 wait end");
            }
        });

        Thread t3 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("t3 wait begin");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t3 wait end");
            }
        });

        Thread t4 = new Thread(() -> {
            System.out.println("t4 notify begin");
            Scanner sc = new Scanner(System.in);
            sc.next();
            synchronized (lock) {
                lock.notify();
            }
            System.out.println("t4 notify end");
        });

        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

        多次使用notify可以唤醒多个线程(同一个Object对线),如果使用过多的notify并不会有什么影响。

(4)notifyAll

唤醒所有wait的线程:

(5)线程饿死问题

        线程饿死(Thread Starvation)是多线程编程中的一种现象,指某个或某些线程由于无法持续获得必要的系统资源(如CPU时间片、锁、I/O访问等),导致其长时间或永久无法执行任务。这种情况通常由资源分配策略不公平、线程优先级设置不当或同步机制设计缺陷引起。例如,当高优先级线程持续占用CPU,或非公平锁导致某些线程始终无法获取锁时,低优先级或竞争能力弱的线程将陷入无限等待,无法推进其任务。

四.多线程代码案例

1.单例模式

        单例模式是一种设计模式,它的核心目标是让一个类在整个程序中只有一个实例,并提供一个全局访问点来获取这个实例。简单来说,就是确保某个类“只能有一个对象”,无论你从哪儿调用它。

(1)饿汉模式

        饿汉模式中,获取instance时,是直接通过return获取的,相当于直接读操作,多个线程进行读操作是不会有线程安全问题的,所以不需要进行加锁操作。

(2)懒汉模式

线程安全问题(图1)

        获取instance时,需要判断instance是否为null,如果为null则还要创建实例化对象的两步操作,此时就可能会出现线程安全问题。

        此时有线程t1和线程t2,t1此时执行判断instance是否为null,随后t2也执行判断instance是否为null,之后t1对instance实例化,t2此时也再次进行实例化,这就造成了两次实例化对象。

        即使第二次创建instance覆盖了原来的值,使得原来的instance没有引用指向被垃圾回收机制回收。即使如此,也认为代码有BUG。

线程安全问题(图2)

        还需要注意的是:进行对象实例化时,需要进行三条指令:1)分配内存空间;2)执行构造方法;3)内存空间的地址,赋值给引用变量。

        线程t1和线程t2在执行代码时,线程t1进行实例化对象时的指令顺序是1和3时,此时instance会被赋值,但是并没有调用构造方法初始内存。

        此时引用不在是为null,而指向值全部为“0”的内存,此时线程t2进行对instance的值判断就肯定不为null,但是instance引用的是值为0的内存,此时返回t2线程返回的instance是没有意义的,也会造成后续方法中使用到instance的调用错误。

2.阻塞队列

(1)阻塞队列的专业介绍

        阻塞队列(Blocking Queue) 是一种线程安全的队列数据结构,常用于多线程编程中的生产者-消费者模型。

        其核心特性是:当线程试图从空队列中获取元素时,或向已满队列中添加元素时,线程会被自动阻塞(暂停执行),直到队列状态满足操作条件(如队列非空或队列有空间)。

        这种机制通过隐式的线程调度(如使用锁、条件变量等同步机制)实现,避免了显式的轮询(busy-waiting),从而提升资源利用率。

(2)生产者-消费者模型

        简单地说,奶茶店的柜台就是阻塞队列,店员是生产者,客户是消费者。此时规定,柜台(阻塞队列)只能摆放5杯奶茶,当柜台上没有奶茶是,店员就需要一直做奶茶,但是客户没办法从柜台上拿奶茶。

        同理,当柜台上放满了5杯奶茶,店员则不能继续做奶茶,需要等待客户把奶茶拿走之后才能继续做奶茶。这就是生产者-消费者模型的简单解释。

好处

        1)服务器之间的解耦合(耦合代表模块之间的关联程度,耦合度越高,模块之间的关联度也就越高)(下图解释解耦合)

        2)通过中间的阻塞队列,可以起到削峰填谷的效果,在遇到请求量激增突发情况下,可以有效保护下游服务器不会被请求冲垮。

       通过上图可知,当A突然收到剧增的请求量,此时向阻塞队列中写入的数据会变快,但是此时B并不会被A的影响,依旧按照原有的速度进行消费数据,这是通过阻塞队列来抗下过多数据的压力。

        3)服务器收到过多的请求,会挂的原因是:一台服务器类似于一台电脑,服务器上提供了一些硬件资源(不限于CPU,内存,硬盘,网络带宽......)

        服务器每收到一个请求,处理这个请求的过程中,就都需要执行一系列的代码,在执行这些代码过程中,就会需要消耗一定的硬件资源(CPU,内存,硬盘,网络带宽......)

        当这些请求消耗的总硬件资源过量时,此时机器就会出现问题(卡死,程序崩溃等一些列问题)

        4)在请求激增时,A不会挂,B反而容易挂是因为:A的角色类似于网关服务器,收到客户端的请求后,再把请求转发给其他服务器,类似于A这样的服务器里面的代码,做的工作比较简单,消耗的硬件资源会更少。

        处理一个请求消耗的资源更少,同样的配置下,就能支持更多的请求处理。同理,队列也是一个比较简单的程序,单位请求消耗的硬件资源也是比较少的。

        B服务器是真正干活的服务器,要真正完成一系列的服务器业务,这一系列的工作代码非常的庞大,消耗的时间和硬件资源更多。

        类似于MySQL这样的数据库,处理每个请求的时候,做的工作是比较多的,消耗的硬件资源也是比较多的,因此MySQL也是后端系统中,容易挂的部分。

        但类似于Redis这样的内存数据库,处理请求做的工作远远少于MySQL做的工作,消耗的资源更少。Redis就比MySQL更加不容易挂。

代价

1)需要更多的机器,来部署这样的消息队列。

2)A和B之间的通信延时,会变得更长。

(3)Java库的阻塞队列

        在Java库中,阻塞队列是一个接口,创建对象只能通过其他的类进行:

        下图中,都是可以实现阻塞队列的类:

        阻塞队列的增加元素和删除元素都是有专属的方法:

当put的次数大于容量时,会进行阻塞:

take过多超过容量中的数据量时,也会进行阻塞:

(4)实现阻塞队列

        阻塞队列的实现无非是实现两个方法put和take,put和take方法中最重要的就是数组中的元素满是如何处理,我只说我的处理方式。

        当数组满时,如果此时tail尾结点在最后一个位置,那么此时尾结点变成下标0位置,当head头节点也在数组最后一个位置时,也变成下标0位置。

       要起阻塞效果只有当数组为满或者为空时进行加锁,当数组为空时,对take方法进行wait等待,等到put方法插入数据时,对take方法进行notify通知后才能进行take方法的操作。(当数组满时put同理)

        在wait的源代码中,对于wait的使用最好是在while循环当中,是因为,wait可能会被异常或者Interrupt等一些非使用者的方式唤醒,当不是被使用者唤醒后,可以通过再次判定条件进行wait等待。

        后续的size++时,可是使用volatile来进行内存可见性问题进行确保安全,防止多线程出现内存可见性问题。

class MyBlockingQueue <T>{
    T[] data = null;

    //头节点
    public volatile int head;
    //尾结点
    public volatile int tail;
    //当前长度
    public volatile int size;


    public  MyBlockingQueue(Integer capacity) {
        data = (T[]) new Object[capacity];
    }


    //插入数据
    public void put(T s){
        synchronized (this) {
            //满队列
            //使用while是为了确保wait被唤醒之后,再次确定if条件,是否能继续执行,防止被Interrupt等其他给唤醒而非通知唤醒
            while (size == data.length) {
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

            data[tail] = s;
            tail++;
            if(tail >= data.length) {
                tail = 0;
            }
            size++;
            this.notify();
        }
    }

    //删除数据
    public T take() throws InterruptedException {
        //空队列
        T ret = null;
        synchronized (this) {
            while (size == 0) {
                this.wait();
            }

            ret = data[head];
            head++;
            if(head >= data.length) {
                head = 0;
            }
            size--;
            this.notify();
        }
        return  ret;
    }

}

3.线程池

线程池总结的博客传送门:线程池总结_一个线程和多个队列-CSDN博客

4.定时器

定时器博客传送门:定时器介绍-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值