线程安全-重点

本文详细探讨了线程安全在Java中的重要性,通过实例分析了synchronized关键字的互斥和可重入特性,以及死锁的产生条件和解决方法。同时介绍了volatile关键字的内存可见性作用,并对比了两者在保证程序正确性的不同点。
摘要由CSDN通过智能技术生成

目录

1.概念

2.产生原因

3.解决办法

3.1 synchronized关键字

3.1.1 特性

3.1.2 死锁

3.1.2.1 产生情况

3.1.2.2 产生原因

3.1.2.3 解决

3.1.2.4 面试题

3.2 volatile关键字

3.3 内存可见性


在上篇博客中,我们学习了进程和线程的概念,以及线程的一些内容,这篇博客我们继续学习线程中的一个重点内容—线程安全,这也是线程里面最复杂的一部分。

1.概念

如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

接下来,我们用代码直观感受一下线程不安全。(答案与预期不符合)

public class Demo13 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            //对count 进行自增
            for (int i = 0; i < 50000; i++) {
                    count++;
                }
        });
        Thread t2 = new Thread(() -> {
            //对count 进行自增
            for (int i = 0; i < 50000; i++) {
                    count++;
                }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //预期结果是10w
        System.out.println(count);   //但结果不唯一
    }
}

我们可以直观看到答案与我们预期结果10w不相符,这就是一个典型的线程不安全。

经过这样更改了之后,虽然达到了预期结果10w,但也不是我们想要的,因为这样虽然是写在两个线程中了,但并不是“同时执行”,即在线程t1执行的时候,t2并不会启动。

2.产生原因

1.操作系统中,线程的调度顺序是随机的(抢占式执行),罪魁之首,万恶之源(系统内核里实现的,没办法进行修改)

2.两个线程,针对同一个变量,进行修改 (有些情况下可以通过调整代码结构,规避上述问题)

3.修改操作,不是原子的(此时的count++,就是非原子的操作,先读,再修改)(想办法让count++这里的三步走,成为原子的   -  加锁)

4.内存可见性问题

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

5.指令重排序问题

是编译器的一种优化模式。

3.解决办法

加锁就可以解决,那么如何进行加锁呢?

其中最常通过使用synchronized关键字

synchronized修饰的是一个代码块,同时会指定一个“锁对象”  -> 有且只有一个,当两个线程同时尝试对一个对象加锁,此时就会出现“锁冲突”/“锁竞争” 一旦竞争出现,一个线程能够拿到锁,继续执行代码;一个线程拿不到锁,就会阻塞等待,等待前一个线程释放锁之后,它才有机会拿到锁,

继续执行  ->  本质上把“并发执行”改成了“串行执行”

public class Demo13 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            //对count 进行自增
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {     //加锁  ()中需要表示一个用来加锁的对象,这个对象是啥不重要,
                                            // 重要的是通过这个对象来区分两个线程是否竞争同一个锁
                    count++;
                }

            }
        });
        Thread t2 = new Thread(() -> {
            //对count 进行自增
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //预期结果是10w
        System.out.println(count);   //但结果不唯一
    }
}

3.1 synchronized关键字

-监视器锁 monitor lock

3.1.1 特性

1.互斥

synchronized会起到互斥效果,个线程执⾏到某个对象的synchronized中时,其他线程如果也执⾏到同⼀个对象synchronized就会阻塞等待.

• 进⼊synchronized修饰的代码块,相当于加锁

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

2.可重⼊

synchronized同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题。

所谓的可重入锁,指的是,一个线程,连续针对一把锁,加锁两次,不会出现死锁,满足这个要求,就是“可重入”,不满足,就是“不可重入”

3.1.2 死锁

3.1.2.1 产生情况

1.一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了(synchronized不会出现)

2.两个进程,两把锁(此时无论是不是可重入锁,都会死锁)

t1 t2  A B

1).t1获取锁A,t2获取锁B

2).t1尝试获取锁B,t2尝试获取锁A

​
public class Demo15 {
    private static Object locker = new Object();

    public static void func1() {
        synchronized (locker) {
            func2();
        }
    }
    public static void func2() {
        func3();
    }
    public static void func3() {
        func4();
    }
    public static void func4() {
       synchronized (locker) {
           
       }
    }
}

​

这样就构成死锁了。

3.N个线程,M把锁(相当于2的扩充)

此时,是更容易出现死锁的情况了

“哲学家就餐问题”  -> 1.思考人生 2.吃面条  -> 哲学家=线程  筷子=锁

3.1.2.2 产生原因

四个条件,缺一不可

1.互斥条件(锁的基本特性) 当一个进程持有一把锁之后,另一个进程也想获取到锁,就要阻塞等待

2.不可抢占(锁的基本特性) 当锁已经被线程1拿到之后,线程2只能等待线程1主动释放,不能强行抢占过来

3.请求保持(代码结构) 一个线程尝试获取多把锁(先拿到锁1之后,再尝试获取锁2 ,获取的时候,锁1不会释放)

4.循环等待 等待的依赖关系,形成环了

3.1.2.3 解决

解决死锁,核心就是破坏上述条件,只要破坏一个,死锁就不能形成了

1和2破坏不了(自带特性,无法干预)

对于3来说,调整代码结构,避免编写“锁嵌套”逻辑

对于4来说,可以约定加锁的顺序,就可以避免循环等待(针对锁,进行编号,比如约定,加多把锁的时候)

public class Demo16 {
    private static Object locker1 = new Object();   //醋
    private static Object locker2 = new Object();   //酱油
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (locker2) {
                System.out.println("t1 加锁成功");
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (locker2) {
                System.out.println("t2 加锁成功");
            }
        });
        t1.start();
        t2.start();
    }
}

3.1.2.4 面试题

你是否了解死锁,谈谈你对死锁的理解

3.2 volatile关键字

volatile能保证内存可⻅性

volatile修饰的变量,能够保证"内存可⻅性"

计算机运行的程序/代码,经常要访问数据

这些依赖的数据,往往会存储在内存中(定义一个变量,变量就是在内存中)

cpu使用这个变量的时候,就会把内存中的数据先读出来,放到cpu寄存器中,在参与运算(load)

cpu读取内存的这个操作,其实非常慢(cpu进行大部分操作,都很快,一旦操作到读/写内存,速度就一下就很慢了)

为了解决上述问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就可以提高整体程序的效率了(重要)

public class Demo17 {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                
            }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            System.out.println("请输入isQuit:");
            Scanner scanner = new Scanner(System.in);
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

并不会退出,即出现了bug

由于多线程引起的,也就是线程安全问题

此处的问题,就是“内存可见性”引起的,即编译器进行代码优化

而我们要做到的是,告诉编译器不要优化,于是volatile就是解决方案。

public class Demo17 {
    private volatile static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
//         
            }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            System.out.println("请输入isQuit:");
            Scanner scanner = new Scanner(System.in);
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

但是,我们好像发现,还有一种办法,不使用volatile也可以正常退出。

public class Demo17 {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                try {
                    Thread.sleep(1000);   //加上sleep
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 退出");
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            System.out.println("请输入isQuit:");
            Scanner scanner = new Scanner(System.in);
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

即加上sleep,那么这是为什么呢?

其实就是因为加上sleep后,while执行速度就变慢了,不会触发load的优化,也就没有触发内存可见性问题了。

上述两个办法虽然都可以,但是还是使用volatile更加靠谱!

3.3 内存可见性

关于内存可见性,还涉及到一个关键概念,JMM(Java Memory Model,Java内存模型)

将内存分为主内存和工作内存(main memory和work memory)

t1线程,对应isQuit变量,本身是在主内存中的

由于此处的优化 就会把isQuit变量放到工作内存中

进一步的t2修改主内存的isQuit,不会影响到t1的工作内存(包括了CPU的寄存器和缓存)

volatile和synchronized有着本质的区别。synchronized能够保证原⼦性,volatile保证的是内存可⻅性。

volatile可以解决内存可见性的问题,但不能保证原子性。至于synchronized是否能保证内存可见性,依旧存疑ing。

以上就是我们关于线程安全的学习了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值