JavaEE-线程安全问题&多线程编程(1)

目录

锁的操作与特性

可重入锁

死锁


多线程编程中最核心的部分:线程安全问题。

线程之间是随即调度-抢占式执行,这样的随机性就会导致程序的执行结果发生变数,有的时候有些结果不是我们想要的结果,就会导致bug,多线程代码引起了bug,这样的问题就是线程安全问题,存在线程安全问题的代码被称为不安全代码。

先来看一个经典的线程不安全栗子:

public static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        Thread t = new Thread(()->{
            for (int i = 0; i < 50000 ; i++) {
                count++;
            }
        });
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000 ; i++) {
                count++;
            }
        });
        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println(count);
    }
}

看这段代码块,是不是感觉count应该等于十万呢,实际并不然,答案是一个不确定数字

导致这个结果的原因就是线程不安全问题,为了理解这个问题我们需要从cpu开始,cpu执行指令就是执行一个线程的过程,刚才代码主要执行的任务就是那句count++,这一行代码其实是三个cpu指令,是非原子指令,三步为:(1)将内存中count的值读取到cpu寄存器->load (2)将寄存器上的值进行计算->add (3)将寄存器上的计算结果读取到内存count里->save;现在是两个并发执行的count++所以在进行这三步时的顺序会存在变数,会存在很多种情况,下面我列举的情况只是其中之一:

这样情况累计的发生就会导致结果的偏差,一个线程刚对变量计算完后另一个线程会把这个变量变为计算前的结果。

导致线程线程不安全问题的原因:

(1)线程在系统中的调度是随机的,抢占式。

(2)当前代码中,多个线程修改同一个变量。

(3)线程针对变量的修改操作,不是原子的。

(4)内存可见性问题

(5)指令重排序

解决线程安全问题,要从原因入手:

第一个原因是无法干预的

第二个原因是一个切入点,但在java中不是很普适,只针对特定的情境可以用,因为当前代码就是需要多个线程要修改用一个变量,但是有的地方可以这么设定。

为什么java标准库要把String设置为不可变对象?

(1)很好的保护了线程安全 (2)有稳定的hash值 (3)方便在常量池缓存

第三个原因的解决是很普适的方法,重点!!

可以通过一些操作将非原子操作打包为原子操作--加锁

锁的操作与特性

锁,本质上是操作系统内核提供的功能,通过api给应用程序,JVM又对这样的api进行了封装。

关于锁主要操作两个

(1)加锁:t1加上锁后t2也尝试加锁就会阻塞等待,直到t1解锁;

(2)解锁:直到t1解锁了,t2才有可能加锁。

锁的主要特性:互斥

一个线程获取到锁后,另一个线程尝试加锁就会阻塞等待,也叫锁竞争/锁冲突。代码中可以创建多个锁,只有多个锁竞争同一把锁才会产生互斥,竞争不同的锁不会产生。

注意看这个代码块,synchronized就是锁,括号内的对象是由object创建的,意思是指java中任何一个类的对象都能作为锁对象,注意:(1)锁对象的用途有且只有一个,就是用来区分多个线程是否针对同一个对象进行加锁,如果是,就会出现锁竞争/锁冲突。 (2)synchronized后跟着的代码块,进入到代码块就是给上述锁对象进行加锁操作,出了代码块就是解锁操作。

如果对刚才代码进行加锁后,再画一次时间轴

当t2开始lock的时候就会阻塞等待(锁在t1),等t1解锁后才可以进行加锁然后执行任务,这样的阻塞就会导致t2的load的在t1的save后,强行制造出串行执行的效果。操作系统里面的加锁解锁功能核心还是cpu提供的指令。

根据上述加锁操作,我们修改一下刚才的代码测试一下运行结果:

此时我们想要的答案就已经出现。锁不仅可以加在方法里面也可以加在方法上,没什么区别

此时将锁放在了方法体上,这个时候锁的生命周期和方法的生命周期相同,就可以把synchronize加在方法上。此外需要注意:this指的就是调用这个方法的对象。

这个写法与上面是等价写法,这个相当于一进入这个方法就针对this加了锁。

synchronize针对普通方法默认对this加锁,有一个特殊的static方法没有this。synchronize修饰静态方法相当于针对该类的类对象加锁,通过类名.class的方法可以获取到类对象。

 synchronized void func(){
        count++;
    }
    
    ///
    static void func1(){
        synchronized (Counter.class){

        }
    }

func方法this可能指向的是不同的锁对象,取决于创建几个对象,而第二种写法多个线程调用func1都会触发锁冲突,在实际开发中根据不同的情况选择合适的方法。

原子指令:不可被拆分的指令,跟原子的意思差不多,是很小的单位。

可重入锁

当我们在方法上加了synchronize的同时在线程的方法体上也加了synchronize,如下代码所示,那么还能正常运行吗?

class Counter{
    synchronized void func(){
        count++;
    }
}
 public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 10000 ; i++) {
                synchronized (counter){
                    counter.func();
                }

            }
        });
}

首先根据我们上面的思路,当t1线程针对counter锁对象时调用func方法,func方法尝试针对counter加锁时应该阻塞等待,等待t1线程解锁,从而造成一个等一个陷入死锁的情况,那么我们运行一下看实际情况:

反转来了:可以看出结果没有任何问题,也运行成功了没有抛出异常。上述推理在synchronize中是并不存在的,是因为JVM自己在内部进行了特殊处理,每个锁对象里会记录当前是哪个线程拥有此锁,当针对这个对象进行加锁操作就会进行判断,当前加锁线程是否是持有锁的线程,如果不是,就阻塞,是,就放行。

此时有一个问题:若有若干把锁,JVM是如何判定当前的右括号是最后一个右括号,也就是什么时候该解锁呢?很简单,运行时给锁对象里加一个计数器,遇到左括号就++右括号就--,当计数器为0就解锁~~

可重入锁这样的机制,就是为了防止程序员粗心大意搞出死锁。

死锁

死锁有三种比较典型的场景

1)锁是不可重入锁,并且一个线程针对一个锁对象连续加锁两次,通过引入可重入锁问题就迎刃而解了。

(2)两个线程两把锁

(3)M个线程N把锁

对于(2)的场景:现在有线程A与B,线程A与B都要获取锁A、B,线程A拿到锁A后不释放A去拿锁B,线程B也同理,让两个线程先各自拿到一把锁再去拿对方的锁。代码如下:

public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                System.out.println("t1获取锁1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1获取锁2");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                System.out.println("t2获取锁2");
                synchronized (locker1){
                    System.out.println("t2获取锁1");
                }
            }

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

此时代码没有结束,但陷入了死锁,也就是t1等t2,t2也在等t1,进程没有退出,但也不会再执行下一步。通过jconsole查看线程状态

这两个线程都处于阻塞状态,状态名称是BLOCKED,也就是因为锁导致的阻塞问题,状态后面的拥有者就是当前导致堵塞状态的锁现在被哪个线程拥有,这时可以知道,当实际开发中遇到死锁问题可以通过上述栈+状态来定位。

场景三:随着线程与锁的数量增加,问题变得更加复杂

可以通过约定获取锁的顺序有效解决这种情况,比如约定获取比自己序号小的那一个锁,那么第一个线程就会阻塞等待,当最后一个线程执行完后依次解锁。

重点!!死锁的四个必要条件:

1、锁具有互斥特性(基本特点)

2、锁不可抢占,一个线程拿到锁后除非自己释放,否则别人也无法抢占(基本特点)

3、请求和保持 一个线程拿到一把锁后,不释放的前提下就去获取别的锁(代码结构)

解决办法:尽量不要让锁嵌套获取

4、循环等待 多个线程获取多个锁的过程中出现了循环等待,A等待B,B也在等待A(代码结构)

解决办法:破除循环等待,即使出现嵌套也不会造成死锁,约定好锁的调用顺序,让所有线程按固定的顺序来获取。

下篇文章更新内存可见性问题等其他线程安全问题。

感谢观看

道阻且长,行则将至

  • 29
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值