【多线程】(基础二)

发布于:2023-01-12 ⋅ 阅读:(556) ⋅ 点赞:(0)

线程安全问题

多线程带来的风险

线程安全问题:在操作系统的随机调度下 ,多个线程的并发执行会产生多种可能,可能会产生BUG

package thread;

class Test{
     int a;
     public void func(){
         a++;
     }
}

public class Demo8 {
    public static Test test = new Test();

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    test.func();
                }
            }
        };
        Thread thread1 = new Thread(runnable1);

        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    test.func();
                }
            }
        };
        Thread thread2 = new Thread(runnable2);

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(test.a);
    }
}

  • 我们使用两个线程对同一个变量分别自增了5000次,按理来说最后结果应该是10000,下面我们来看结果

image-20220801203826775

image-20220801203839614

  • 可以看出运行多次结果都不相同,不是10000,原因是:

a++; 这一行代码对应了三条机器指令:

从内存读取数据到CPU(load),在CPU寄存器中完成加法运算(add),把运算结果放到内存中(save)

那由于两个线程的执行是调度器随机调度的,所以在某一时刻,这两个线程可能都在CPU的不同核心上运行(并行执行),也可能只有一个在CPU上执行,也可能俩线程都没在CPU上执行。如果俩线程并行执行那就可能会出问题,下面具体来看:

image-20220801212243325

  • 如果线程在执行时有这样的时间关系,也就是线程1从Load到Save的时间段内又有线程2的++操作穿插进来了,那么两次Load读到的值是一样的,假如都Load了个0,则Save了1,那这两个++操作的执行效果其实是和一次++一样的,明明是两次自增操作,结果却只增了一次,这就是典型的线程不安全。
  • 那其实也有可能线程1和2的++操作是完全串行化的,那两次自增操作,结果就会加2
  • 极端情况下线程1和线程2中的所有++操作都如上图一样,那最终的值就是5000,如果线程1和线程2是完全串行化的,那最终结果就是10000,所以最后的值范围是5000~10000之间

线程不安全的原因

操作系统随机调度

操作系统随机调度/抢占式执行,是造成线程安全问题的罪魁祸首,这个是操作系统的调度器的逻辑,我们是无法改变的

多个线程修改同一个变量

image-20220802103248291

在多线程带来的风险演示的代码中test.a在堆区,各个线程共享,如果多个线程同时修改同一个变量,就有可能造成多线程带来的风险中的bug

非原子性

有些修改操作不是原子的(不可分割的最小单位),比如上面的++操作,就对应了三个指令,这就不是原子的,而 =(赋值)就对应了一条机器指令,就是原子的。

内存可见性

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

在下面这样的一个场景中:一个线程读,一个线程写就比较容易引发内存可见性问题

image-20220802112959056

线程1在频繁的读和判断,如果中间线程2突然写了一下内存(线程1和线程2都是针对同一个数据),那线程2写完之后,线程1就能立刻读出变化了的内存数据,从而让判断出现变化。

但是程序在运行中,可能会出现优化,这个优化可能是编译器的优化,也可能是JVM的优化,也可能是操作系统的优化,下面具体来看一下如何优化的:

image-20220802113827342

Load是读内存,Test是在寄存器中做判断,所以这个Load操作就比Test消耗时间多得多,那既然线程1频繁读内存操作都是读取到的同一个值,又耗时这么高,那所以JVM就做出优化:不再重复的从内存中读了,直接复用第一次从内存读到寄存器中的值进行每次的判断

那一优化就可能会出现问题:假如判断过程中,线程2执行了一次写操作,把这个共享的数据给改了,那线程1是感知不到内存数据的变化的,线程1还是用的寄存器中的值做判断,没有从内存中读数据,那就不能及时的做出相应的反应,这就是内存可见性问题(内存被修改,但是读不到,看不见),但是对于单线程就没有这样的问题,单线程中,你去写内存,CPU中执行的是写的指令,没有执行判断的指令,然后写完之后,再去判断,就从内存读数据再判断,判断和写肯定是有先后关系的,但是多线程就是并发的关系,写的时候依然可以进行判断

用volatile就可以解决这个内存可见性问题,让某个变量不要优化

指令重排序

指令重排序是对代码的执行顺序进行调整,以提升运行速度,也是编译器/JVM/操作系统的一种优化,但是在多线程的环境下,就可能会产生BUG

比如 Test test = new Test(); 可以分为三个指令 1.创建内存空间,2.往这个内存空间上构造一个对象,3.test引用这块内存空间的地址 ,而2和3是可以调换顺序的,在单线程下调换顺序是没啥影响的,但是如果在多线程下:

如果按照2,3的顺序执行,在另一个线程下获取到的test就是一个有效地址,如果按照3,2执行,那获取到的test就可能是一个无效地址(地址所对应的内存空间中没有

指令重排序也是可以用volatile避免这种问题

synchronized

synchronized作用

synchronized的意思是使同步

synchronized 关键字可以给某个对象加锁,以保证原子性,具体给哪个对象加锁,下面再分析

package thread;

class Test{
     int a;
     public synchronized void func(){
         a++;
     }
}

public class Demo8 {
    public static Test test = new Test();

    public static void main(String[] args) throws InterruptedException {

        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    test.func();
                }
            }
        };
        Thread thread1 = new Thread(runnable1);

        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    test.func();
                }
            }
        };
        Thread thread2 = new Thread(runnable2);

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(test.a);
    }
}

image-20220802204128483

这里加上synchronized关键字之后,可以看到结果正确了。下面具体分析一下这个关键字的作用

image-20220802205425429

加上synchronized关键字之后,执行自增操作前就会先执行Lock指令,然后执行完自增操作后,就会执行Unlock指令,而这个Lock指令是互斥的,意思就是当执行了Lock指令后,到执行Unlock之前,线程 2是不能执行Lock的,此处无法执行Lock指令那么就会进入阻塞状态(Blocked),等线程1执行了Unlock之后,线程2才有可能执行Lock,如果线程2执行了Lock,那么线程2就拿到了锁。

如果线程1加锁之后,线程2再尝试对同一个对象加锁,线程2就会阻塞(对应的PCB进入阻塞队列),线程2进入BLOCKED状态。而当线程1解锁之后,线程2也不是立即就能拿到锁,还得等操作系统去唤醒线程2(这也是操作系统线程调度的一部分任务),唤醒就是线程对应的PCB由阻塞队列加入到就绪队列。所以一个线程如果因为竞争锁而进入阻塞状态,那得靠操作系统来唤醒,如果是sleep(),那时间到了就唤醒。

注意;如果线程1执行了Unlock之后,线程2也不一定就能拿到锁,因为如果线程2还有其他竞争者,而锁只有一把,所以不一定能拿到锁。在加锁之后,线程1和线程2就得分时间执行,线程1执行的时候,线程2不能执行,线程2执行的时候,线程1不能执行。也就是俩线程得岔开执行,按如下方式:

image-20220802211510595

加锁虽然消除了这种bug,但是加锁同时引入程序的运行效率降低了,因为一个线程执行时,另一个线程就得阻塞等待(并发不起来)

假如t1解锁之后,t2能立刻拿到锁吗?

这是不一定的,还得看操作系统的调度,

synchronized修饰方法和代码块

加锁操作是针对实际的对象来进行的,也就是这把锁是加到对象身上的,而synchronized修饰不同的方法和代码块加锁的对象也是不同的,下面具体来看一下:

修饰普通成员方法:

class Test{
     int a;
     public synchronized void func(){
         a++;
     }
}
  • 相当于是给this(当前对象加锁),如果线程1拿到了当前对象的锁,那线程2拿到其他实例对象的锁那也是可以的

修饰静态成员方法:

class Test{
     int a;
     public synchronized static void func(){
         
     }
}
  • 相当于是给类对象加锁 (类对象只有一个),类对象就是在反射里每个类对应的那个class对象;如果是给类对象加锁,那么只要有一个线程调用了这个方法,在这个线程给这个类对象加锁期间,别的线程就不能调用这个方法,因为类对象只有一个

⭐️synchronized修饰方法,则称该方法为同步方法⭐️

修饰代码块:

class Test{
     int a;
     public void func(){
         synchronized (this){
             a++;
         }
     }
}
  • synchronized修饰代码块可以指定加锁的对象,小括号里的this就是加锁的对象,代表给当前对象加锁
class Test{
     int a;
     public void func(){
         synchronized (Test.class){
             a++;
         }
     }
}
  • 这个就代表给类对象加锁,在一个线程中有一个实例对象调用了这个方法,就给类对象加了锁,其他线程中再使用其他实例对象也是调用不了这个方法的,因为类对象只有一个(一个对象只有一把锁)

    img

✅synchronized修饰代码块,则该代码块称为同步代码块

小结:只有当两个线程针对同一个对象加锁的时候才会产生竞争(让其中一个线程阻塞等待)

加锁:每个对象都仅有一把锁,某个线程拿到锁之后别的线程就拿不到了

加锁本质:加锁是针对对象加的,比如说有个Test类,Test实例(new Tes())可以作为加锁的对象,Test类对应的类对象(Test.class)也可以作为加锁的对象

image-20220803085056916

对象里其实有块空间对应了锁的状态,给这个对象加锁,其实就是改变了对象中锁的状态

下面通过代码来看一下

package thread;

class Test1{

}
public class Demo9 {
    public static void main(String[] args) {
        Test1 test1 = new Test1();
        Test1 test2 = new Test1();
        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                synchronized (test1){
                    System.out.println("线程1开始");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程1结束");
                }
            }
        };
        Thread thread1 = new Thread(runnable1);
        thread1.start();

        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {
                synchronized (test1){
                    System.out.println("线程2开始");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程2结束");
                }
            }
        };
        Thread thread2 = new Thread(runnable2);
        thread2.start();
    }
}

image-20220803092333667

上面代码中两个线程都针对test1对象加锁,从执行结果中可以看出线程1先给test1对象加了锁,等线程1执行完了,线程2才能拿到锁。当然也有可能先是线程2拿到锁,线程1后拿到锁,因为虽然thread1.start()比thread2.start()早,但是线程1不一定比线程2先执行,只是线程1比线程2创建早,具体哪个线程先执行,还得看操作系统的调度。哪个线程先执行了Lock指令,哪个线程就先拿到了锁。(线程1先加了锁,线程2再去尝试执行加锁,那线程2就会阻塞等待)


如果两个线程针对不同的对象加锁呢?

package thread;

class Test1{

}
public class Demo9 {
    public static void main(String[] args) {
        Test1 test1 = new Test1();
        Test1 test2 = new Test1();
        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                synchronized (test1){
                    System.out.println("线程1开始");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程1结束");
                }
            }
        };
        Thread thread1 = new Thread(runnable1);
        thread1.start();

        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {
                synchronized (test2){
                    System.out.println("线程2开始");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程2结束");
                }
            }
        };
        Thread thread2 = new Thread(runnable2);
        thread2.start();
    }
}

image-20220803093824184

image-20220803093909798


  • 这里俩线程针对不同的对象加锁,是不会产生竞争的,线程1先开始,线程1结束前,线程2就开始了,所以线程2没有去阻塞等待线程1执行完;
  • 这里线程1和线程2哪个先开始是不确定的,因为依赖调度器的调度,同样,哪个先结束也是不确定的,sleep(1000)执行完,PCB从阻塞队列挪回就绪队列,依然要靠调度器的调度。

volatile

⭐️volatile关键字可以用来解决内存可见性问题:

package thread;

import java.util.Scanner;

public class Demo10 {
    static class Counter{
        public int a = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() ->{
            System.out.println("t1启动");
            while (counter.a == 0){

            }
            System.out.println("t1结束");
        });
        thread1.start();

        Thread thread2 = new Thread(()->{
            System.out.println("t2启动");
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入:");
            int tmp = scanner.nextInt();
            counter.a = tmp;
            System.out.println("t2结束");
        });
        thread2.start();

    }
}


image-20220803112903126

这个程序我们期望在线程2中修改a的值,可以让线程1结束,但是当输入10之后,发现程序一直不结束,这个问题在上面的内存可见性问题中已经说明过了,是编译器 做出的优化才导致内存不可见,也就是不能及时的发现内存中数据的变化。我们使用了volatile 关键字之后就可以避免这种问题

package thread;

import java.util.Scanner;

public class Demo10 {
    static class Counter{
        volatile public int a = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() ->{
            System.out.println("t1启动");
            while (counter.a == 0){

            }
            System.out.println("t1结束");
        });
        thread1.start();

        Thread thread2 = new Thread(()->{
            System.out.println("t2启动");
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入:");
            int tmp = scanner.nextInt();
            counter.a = tmp;
            System.out.println("t2结束");
        });
        thread2.start();

    }
}

image-20220803113335733

  • 这个volatile关键字相当于显示的禁止了编译器的优化,给对应的变量加上了内存屏障,JVM在读取这个变量的时候,因为有了内存屏障,就知道要每次从内存中读取,而不是草率的优化

那再看下面的代码:

package thread;

import java.util.Scanner;

public class Demo10 {
    static class Counter{
        public int a = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() ->{
            System.out.println("t1启动");
            while (counter.a == 0){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1结束");
        });
        thread1.start();

        Thread thread2 = new Thread(()->{
            System.out.println("t2启动");
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入:");
            int tmp = scanner.nextInt();
            counter.a = tmp;
            System.out.println("t2结束");
        });
        thread2.start();

    }
}

image-20220803114721202

  • 上面的代码中把volatile去掉了,但是在线程1中加了sleep(),程序也能结束,因为线程1中没有频繁读数据,所以就没有触发编译器的优化,所以每次读数据还是会从内存中读取。那有些时候我们也不好判断到达有没有触发优化,所以每次都加上volatile就可以避免内存可见性问题

volatile解决的是内存可见性问题,但是不解决原子性问题。synchronized是否能解决内存可见性问题呢? 这个有待考证

✅volatile还可以解决指令重排序的问题(因为禁止了编译器的优化)

主内存和工作内存

我们在内存可见性问题中,说编译器做出了优化,然后读数据就不再从内存中读了,直接复用CPU中寄存器的值。而下面的工作内存就可以认为是CPU中的寄存器和缓存,主内存其实就是线程优化时。线程优化时主要是在操作工作内存,没有及时的读取主内存,导致出现误判。本质是一样的,就是多起了一套新名字

下面的流程图java单独起了个名字叫JMM(java memory model),为啥java官方要多搞出这一套呢?

因为java的跨平台性,计算机发展过程中硬件变化很大,早期的CPU中没有缓存,后来慢慢有了一级缓存,多级缓存,正因为硬件的结构不同,在描述内存可见性等问题时,描述不同,为了统一描述,就把CPU中的寄存器,缓存称为了工作内存,把真正的内存称为了主内存。

image-20220803192926731

问题:阻塞的线程阻塞多久呢?阻塞的线程阻塞多久呢,总不能一直阻塞吧

wait() 和notify()

wait()和notify()是一组方法,得配合使用,这组方法也是为了控制线程之间的执行顺序。在之前两个线程给同一个对象加锁后,两个线程会产生锁竞争,然后就只能先执行完一个线程中synchronized修饰的代码块,然后再执行另一个线程中synchronized修饰的代码块,只能是一个先执行完,然后另一个再执行。并且两个线程中synchronized修饰的代码块不好确定哪个代码块先执行(不好确定哪个线程先拿到锁)

而使用了wait()和notify()就可以让一号线程执行synchronized修饰的代码块时,先等二号线程执行完synchronized修饰的代码块,再执行一号线程中synchronized修饰的代码块,两个线程中synchronized修饰的代码块有了确定的执行顺序。


package thread;

import java.util.Scanner;

public class Demo11 {
    static class Test{

    }
    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Thread thread1 = new Thread(() ->{
            synchronized (test){
                System.out.println("线程1开始");
                try {
                    test.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1结束");
            }
        });
        thread1.start();


        Thread thread2 = new Thread(() -> {
           synchronized (test){
               System.out.println("线程2开始");
               Scanner scanner = new Scanner(System.in);
               System.out.println("输入任意内容唤醒线程1");
               scanner.next();
               test.notify();
               System.out.println("线程2结束");
           }
        });
        thread2.start();

    }
}

image-20220804153954501

上述代码中线程1先拿到锁,然后调用了test.wait()释放test对象锁,线程1就会阻塞等待被唤醒;线程1释放锁之后,线程2拿到了锁,把相应的逻辑执行完之后,再执行test.notify(),由test对象去调用notify()的作用:(使因为调用了wait()方法而释放了test对象锁进而阻塞的线程被唤醒,(如果这样的线程有多个那就随机唤醒一个)),唤醒就是:让线程1对应的PCB由阻塞队列进入就绪队列,然后在操作系统的调度下,让线程1尝试获取锁,如果获取到了锁就继续执行相应的逻辑;如果没有获取到锁,可能线程2中的同步代码还没执行完,也就还没有释放锁,那么线程1就会又进入BLOCKED状态,等线程2同步代码执行完,释放了锁,线程1再被操作系统唤醒,再尝试获取锁。

package thread;

import java.util.Scanner;

public class Demo11 {
    static class Test{
        
    }
    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Test test1 = new Test();

        Thread thread1 = new Thread(() ->{
            synchronized (test){
                System.out.println("线程1开始");
                try {
                    test.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1结束");
            }
        });
        thread1.start();
        Thread thread2 = new Thread(() -> {
            synchronized (test){
                System.out.println("线程2开始");

                test.notify();
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程2结束");
            }
        });
        thread2.start();
        Thread.sleep(1000);
        System.out.println(thread1.getState());
    }
}

image-20220804214040080

上述代码中就是线程2还没释放锁,线程1就去尝试获取锁了,就让线程1进入了BLOKCED状态。


wait() : 先释放锁;然后让调用wait()方法的线程进入阻塞状态;等待被唤醒,然后尝试重新获取锁

因为wait()方法要先释放锁,所以wait()方法必须得在synchronized内部使用,且加锁是给哪个对象加的,释放锁的时候也要用那个对象去调用wait()(wait()是Object类中的方法),这样才能把加到那个对象上的锁给释放


Thread thread1 = new Thread(() ->{
    synchronized (test){
        System.out.println("线程1开始");
        try {
            test1.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程1结束");
    }
});
thread1.start();

如果加锁对象和调用wait()的方法不一致,或者wait()方法没有在同步方法或者同步代码块中调用,就会抛出如下异常:

image-20220804154657143

这个异常叫不合法的监视器状态异常


✅notify() :假如在线程1中调用了test.wait(),那线程1先释放test对象锁,然后进入阻塞状态,要想线程1在合适的条件下继续执行,就需要在线程2 中调用test.notify()方法,

✅调用notify()方法得和调用wait()方法的对象保持一致(如果不一致,那线程1无法唤醒),并且notify()方法也得在同步方法或同步代码块中调用。notify()方法的作用就是给那些等待获取该对象的对象锁的线程发个通知,让他们由阻塞状态进入就绪状态,重新尝试获取对象锁。

✅notify()方法也得在同步方法或者同步代码块中调用,并且调用同步方法或同步代码块时加锁的对象也得和调用notify()的对象保持一致,否则就会抛出异常。这个异常和上面的异常是一样的。

✅如果有多个线程因为调用了同一个对象的wait()方法而阻塞等待,那调用notify()时,就会由调度器随机挑选一个线程唤醒,如果想要唤醒所有的线程,就需要用notifyALll()。

wait()和sleep()对比

1️⃣wait()需要搭配synchronized使用,sleep()不需要

2️⃣调用wait()的线程唤醒方式是主动唤醒,sleep()是时间到了之后由操作系统唤醒

3️⃣sleep()后线程状态时TIMED_WAITING ,wait() 后线程状态时WAITING

4️⃣调用wait()是为了控制线程之间执行顺序,调用sleep()只是为了让当前线程暂时放弃CPU


网站公告

今日签到

点亮在社区的每一天
去签到