0
点赞
收藏
分享

微信扫一扫

【JavaEE】Bean的作用域和生命周期

凉夜lrs 2024-07-25 阅读 24

《JavaEE篇》--多线程(1)

线程安全

线程不安全

我们先来观察一个线程不安全的案例:

public class Demo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        Thread t2 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                    count++;
            }
        });
        t1.start();
        t2.start();
        //如果没有join,线程还没执行完,就会打印count
        t1.join();
        t2.join();
        //预期结果应该是十万次
        System.out.println("count: " + count);
    }
}

两个线程同时对一个变量进行修改,按理来说最后的输出结果应该是十万,现在我们运行一下

可以以看到似乎并不像我们预期的那样,每个结果都不一样,而且有些结果和我们预期的相差很大。

上述代码如果放在单线程里肯定是对的,但是如果放在多线程里就会出现逻辑错误了

先要知道怎么回事我们要先了解一下count++这个操作。

Count++这个操作实际上,是分成三步进行的,站在cpu的角度上,count++是由cpu通过三条指令来实现的

  1. load 把数据从内存读到cpu寄存器上
  2. add  把寄存器中的数据+1
  3. save 把寄存器中的数据,保存到内存中

 由于线程之间的调度顺序是随机的,就会导致在有些调度顺序下,就会出现上述逻辑错误,接下来我通过画图的方式详细讲解。

上述两种执行顺序是正常的执行顺序(t1线程执行完整个count++动作,之后t2线程再执行,或者t2先执行),但是由于线程的随机调度,可能当t1执行load和add的操作之后,这时t2插了进来完成自己的操作。就像这样

那么此时会产生什么结果呢?

可以看见此时编译器执行了两次count++操作但是实际的count只被加了一次,这还只是一种顺序,实际编译时两个操作执行count++又会有多少种呢?如果t1执行一次,t2执行两次,三次呢又会有多种?必然是我们无法知晓的。

 综上对于线程安全我们可以粗略的认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。 

 线程不安全的原因

线程安全产生的原因主要有以下五点

那么我们如何保证线程是安全的?我们先暂时针对前三个问题进行解决,先看第一个,这是操作系统的特性我们不好对其进行修改, 那么再看后两个,如果我们保证操作是原子性的,这样不管对变量怎么修改,就都不会出错了。那么怎么保证原子性呢?最好的方法就是加锁

加锁

我们先来解决刚刚的代码

 private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                //当同时对一个对象加锁时,必须要等前一个代码块执行完,后一个代码块才能执行,就可以解决问题了
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            //让count自增5W次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        //如果没有join,线程还没执行完,就会打印count
        t1.join();
        t2.join();
        //预期结果应该是十万次
        System.out.println("count: " + count);
    }

运行结果:

可以看见现在的结果正是我们预期的

synchronized

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待,换言之,当两个synchronized同时对一个对象加锁(什么样对象一般无所谓只要是相同的就行),必须要等前一个synchronized代码块执行完,后一个代码块才能执行。

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

举个例子🌰:

如果A对B表白,并且B同意了此时就相当于A对对象B进行了加锁,此时C就不能再追求B了,只能等A和B分手后(解锁),B又回归单身后,此时C就可以光明正大的追求B了。

//synchronized用的锁是存在Java对象头里的,加锁就相当于对对象头进行了修改

可以粗略的理解成对象再内存中储存的时候,都有一块内存表示对象是否被锁,如果被锁则其他线程不能再次加锁,如果没被锁则可以加锁。

其他写法

这两种方法等价

 //这两种方法等价
    synchronized public void add1(){
        this.count++;
    }
    public void add2(){
        synchronized (this){
            count++;
        }
    }
//这两种方法等价
    synchronized public static void add3(){

    }
    public static void add4(){
        synchronized (Count.class){

        }
    }

这两种方法也等价,上面的写法相当于下面的简化写法 

 可重入

  • 不可重入锁:只判断这个锁有没有被锁上,如果没被锁上就可以对其加锁,如果被锁上就要等待它解锁之后才可以加锁
  • 可重入锁:不仅会判断这个锁有没有被锁上,还会判断这个锁是被谁锁上的,如果是被当前线程锁上的则可以连续加锁,并且不出现死锁

简单来说一个线程,连续针对一个一把锁,加锁两次,不会出现死锁,满足这个要求,就是可重入

不可重入锁

当一个代码块被锁上,此时如果在代码块内部再加上一把锁就会出现死锁,如下

//死锁的发生不一定是在一个方法,也可能会发生在方法调用之间

 可重入锁

可重入锁是一种支持可重入机制的锁,当对一把锁连续加锁两次第二把锁不会出现阻塞(换言之,外层使用锁之后,内层任然可以使用),允许一个线程反复获得该锁,避免了死锁的发生,同时也提高了代码的简洁性的可读性,我们刚刚讲到的synchronized就是可重入锁

我们在对这个案例进行分析,因为synchronized是可重入锁,所以没有因为第二次加锁而死锁,但是当代码执行到(2)时,此时,锁(1)应不应该打开?又或者说,这里有N把锁,那么释放的时机应该如何?

为了解决这个问题,可重入锁设计了加锁次数,每加一次锁就加1,释放一次锁就减1,当计数为0时才真正释放锁,以此来保证所有的加锁过程都解锁了,其他线程才能访问。

关于死锁

1.一个线程针对一把锁,连续加锁两次,如果是不可重入锁,就会发生死锁

2.两个线程,两把锁,t1线程先获取锁A在获取锁B,t2线程先获取锁B在获取锁A(此时无论是不是可重入锁都会死锁),就相当于一个人把车钥匙锁在家里,把家里钥匙锁在车里

 3.N个线程M把锁

关于N个线程M把锁有一个经典的模型--哲学家就餐问题

哲学家就餐问题

 基于上述规则通常情况下,是可以正常运行的,但是当处在极端情况下时,比如五位哲学家同时想吃面,又同时拿起左边的筷子,这时就会出现死锁的情况(这里哲学家就相当于一个线程,筷子就相当于是锁,拿起一个筷子相当于解开了一把锁)。

Volatile

内存可见性

线程之间的共享变量存在主内存(实际物理内存)中,而且每一个线程还有自己的工作内存(寄存器/cpu高速缓存),当线程要读取一个共享变量时,会把变量从主内存拷到工作内存中,再从工作内存读取数据,当线程要修改一个共享变量时,也会先修改工作内存中的副本,在同步到主内存中。这样可能就会导致线程1修改了共享变量a,但是主内存和线程2中数据更改不及时。

内存可见性会带来的问题

我们先来看段代码

正常来将当我们输入1时t1线程就结束了

但是实际上无论我们如何输入1,t1都没有结束,这是怎么回事?

计算机运行的程序,经常要访问数据,这些数据往往会存储在内存中(比如,定义一个变量,变量就会存储在内存中),当cpu使用这个变量的时候,就会把这个变量,先从内存中读出来,再放到cpu的寄存器中,最后在参与运算。cpu读取内存的操作是比较缓慢的,相对而言读寄存器就快的多了。

当编译器在处理isQuit == 0时会涉及到俩条指令1.load读取内存中的isQuit的值到寄存器中,2.通过cmp指令比较寄存器里的值是不是0,然后决定要不要循环。

由于循环执行的速度非常快,短时间内就会完成大量load和cmp操作。这时编译器/JVM就会发现,虽然进行了这么多次load,但是每次load的结果都一样,并且load操作非常消耗时间,比cmp操作消耗的时间多得多,于是编译器就做了一个大胆的就决定,只在第一次循环操作的时候,才读内存,后续都不在读内存了,而是直接从寄存器中取出isQuit的值,这个就是编译器的优化。

这时我们可以利用Volatile来解决这个问题

在多线程的环境下,编译器有时会做出一些不准确的优化,此时就需要我们程序猿自己手动来矫正。我们可以通过Volatile关键字,来告诉编译器不要进行优化。我们直接在刚刚的代码上操作,直接在isQuit前加上Volatile就行。

运行结果

可以看到这时程序成功执行

wait和notify

使用:

 public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("wait 之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("wait之后");
        });
       Thread t2 = new Thread(() -> {
           try {
               Thread.sleep(3000);
           } catch (InterruptedException e) {
               throw new RuntimeException(e);
           }
           synchronized (locker){
               System.out.println("进行通知");
               locker.notify();
           }
       });
        t1.start();
        t2.start();
    }

wait和sleep的区别

以上就是博主对线程知识的分享,在之后的博客中会陆续分享有关线程的其他知识,如果有不懂的或者有其他见解的欢迎在下方评论或者私信博主,也希望多多支持博主之后和博客!!🥰🥰

下一篇博客博主将分享有关单例模式等知识,还希望多多支持一下!!!😊

举报

相关推荐

0 条评论