多线程初阶(二)

内容分享2小时前发布
0 0 0

多线程带来的风险—线程安全(重点)

线程安全问题是多线程环境下的代码出现bug

不理解线程安全问题,很难保证写出正确的多线程代码
引例:
定义两个线程,分别对count自增5000次,预期结果是10000.


public class Demo15 {
    private static int count = 0;

    public static void main(String[] args){
        Thread t1 = new Thread(() -> {
            for(int i = 0; i< 5000;i++){
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0; i< 5000;i++){
                count++;
            }
        });

        t1.start();
        t2.start();
        System.out.println(count);
    }
}

但这个代码输出结果是0,说明main线程,先执行打印了。
解决方法:在主线程中阻塞等待,线程 t1,t2.
代码如下:


public class Demo15 {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i< 5000;i++){
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0; i< 5000;i++){
                count++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

虽然执行了count++,但是结果小于10000.
原因是:count++不是原子操作,站在CPU执行指令的度上来看,count++这个代码对应三个CPU指令:
1、load,把内存中的count值,读取到CPU寄存器。
2、add,把该寄存器的值进行+1操作。
3、save,把寄存器修改的值写回到内存中。
由于在操作系统中线程调度是随机的,CPU在执行这些指令时,随时可能触发”线程切换“操作.中间结果也可能会被覆盖。

要想结果正确,一个线程的load必须要在另一个线程的save之后。

所以引用了锁(synchronized)
代码:


public class Test4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 5000;i++){
                synchronized(locker1){
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 5000;i++){
                synchronized(locker2){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }
}

线程安全问题产生的原因

1、操作系统对于线程的调度是随机的,抢占式执行。(根本原因)
2、多个线程同时修改同一个变量。
3、修改操作不是原子的。
一条Java语句不一定是原子的,也不一定只是一条指令。
比如n++,其实是由三步操作组成的:
1)从内村把数据读取到CPU。
2)将数据更新。
3)把数据写会到CPU。

4、内存可见性(编译器优化带来的问题)
内存可见性是一个线程对共享变量值的修改,能及时被其他线程看到。


import java.util.Scanner;

public class Demo21 {
    private static int flag = 0;
    public static void main(String[] args){
        Thread t1 = new Thread(() -> {
            while(flag == 0){

            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
           System.out.println("请输入flag的值");
           flag = scanner.nextInt();
        });

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

一个线程读取flag,一个线程修改flag,修改flag的值并没有被线程读取到。
出现这种问题的原因是“编译器优化”,编译器会在代码逻辑不变的条件下,对代码进行调整,使程序运行效率高。但编译器的判断可能会失误,尤其是在多线程中,会使逻辑错误。

问:那编译器是怎么优化这个代码而产生问题的呢?

while循环里涉及到两个CPU指令,一个是读取(load)flag的值,另一个是条件跳转(cmp),load是读内存,cmp是CPU寄存器操作,load的时间开销是cmp的几千倍,编译器load很多次发现都是一样的结果,于是load后就不再重新读内存,而直接从寄存器里读

问:解决办法是什么呢?
用volatile关键字。

volatile关键字

volatile修饰的变量,能保证“内存可见性”,但不保证原子性。

private volatile static int flag = 0;

问:内存可见性问题的原因?
线程之间共享的变量存在主内存(内存)。
每一个线程都有自己的“工作内存”(CPU寄存器)。
当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据。
当线程修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存。

问题根源:线程操作共享变量时,会先读取到CPU中,修改后未必刷回主内存,导致其他线程读的是旧的值。

5、指令重排序
指令重排序是编译器或CPU为优化性能,保持逻辑不发生变化的前提下,调整指令的执行顺序。(单线程) 而对多线程可能会导致优化后的逻辑和之前的不等价。

synchronized关键字——监视器锁

synchronized的特性

1)互斥
某个线程执行到某个对象的synchronized时,其他线程也执行到这个对象的synchronized时,就会阻塞等待。
*进入synchronized修饰的代码块就是“加锁”。
*退出synchronized修饰的代码块就是“解锁”。
2)可重入
synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的情况。
列:


public class Test {
public static void main(String[] args){
    Object locker = new Object();
    Thread t = new Thread(() -> {
        synchronized(locker){
            synchronized(locker){
                System.out.println("hi");
            }
        }
    });
    t.start();
}

}

在可重入锁的内部,包含了“线程持有者”和“计数器”
*如果某个线程加锁的时候发现锁已经被占用,但是正好占用锁的就是这个线程,那么可以继续获取锁,并让计数器自增。
*解锁时,当计数器递减为0时,才真正释放锁。

使用synchronized的几种方法

1、修饰代码块,明确指定锁对象。

1)锁任意对象


public class SynchronizedDemo {
    private Object locker = new Object();
    
    public void method(){
        synchronized(locker){
            
        }
    }
}

2)锁当前对象


public class SynchronizedDemo {
    public void method(){
        synchronized(this){

        }
    }
}

2、直接修饰普通方法


public class SynchronizedDemo {
    public synchronized void method(){
        
    }
}

3、修饰静态方法


public class SynchronizedDemo {
    public synchronized static void method(){

    }
}

死锁

第一种:一个线程一把锁(不是可重入锁,synchronized是可重入锁,所以这种情况下不会死锁)。
第二种:两个线程两把锁
(重点)手写重现一个死锁代码:


public class Demo20 {
    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) {
                   throw new RuntimeException(e);
               }

               synchronized(locker2){
                   System.out.println("t1线程两个锁都获取到");
               }
           }
        });

        Thread t2 = new Thread(() -> {
            synchronized(locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized(locker1){
                    System.out.println("t2线程两个锁都获取到");
                }
            }

        });
    }
}

sleep是为了确保t1线程能拿到locker1,t2线程拿到locker2。
如果没有sleep,可能其中一个线程将两个锁一口气都拿到了,就不会死锁。
3、N个线程M把锁(哲学家就餐)

(重)构成死锁的四个必要条件:

1、锁是互斥的,一个线程刚拿到锁后,另一个线程尝试拿到该锁是,必须要阻塞等待。
2、锁是不可抢占的。线程一拿到该锁,线程二也要拿必须阻塞等待。
3、请求和保持
一个线程拿到锁1后,在不释放锁1的前提下,获取锁2。
4、循环等待
多个线程,多把锁的等待过程,构成了“循环”。A等B,B等A 或 A等B,B等C,C等A。

避免死锁的解决办法

第一,第二点是锁的基本性质决定的,是不可改变的。所以要想解决死锁的问题,只要破坏第三和第四其中任何一个条件,就可以打破死锁。
1、(不通用)解决第三点:把嵌套锁改成并列的锁。
这种情况下不是通用的,因为有些情况下需要同时拿到多个锁再进行某个操作。嵌套很难避免
2、解决第四点:
对加锁的顺序做约定。
约定每个线程加锁的时候永远先获取序号最小的锁,再获取序号最大的锁。


public class Demo20 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
           synchronized(locker1){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               synchronized(locker2){
                   System.out.println("t1线程两个锁都获取到");
               }

           }


        });

        Thread t2 = new Thread(() -> {
            synchronized(locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized(locker2){
                    System.out.println("t2线程两个锁都获取到");
                }
            }


        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

    }
}

© 版权声明

相关文章

暂无评论

none
暂无评论...