Java线程安全(上)

互联网架构师

共 5477字,需浏览 11分钟

 ·

2022-04-07 11:31

136a370eb47febab9b3d5e0213c46ba1.webp

前言

我们在Android项目开发中,经常会使用到多线程异步处理很多事务,但是在实际使用线程开发时有些线程概念理解很模糊,再加上一些线程操作误区,导致应用运行线程混乱,“不科学的bug”越来越多,分享本篇文章的目的就是让大家理解线程安全原理,合理使用线程,并从中受到一些设计启发。



01


线程的基本介绍


线程调度

在讲线程安全之前我们需要将线程调度相关知识作为前置条件,计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待CPU,JVM的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU的使用权。

有两种调度模型:分时调度模型和抢占式调度模型。

分时调度模型是指让所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用的CPU的时间片这个也比较好理解。

Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU。


JMM

JMM(Java Memory Model),是一种基于计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。保证共享内存的原子性、可见性、有序性。

fffcedb5a3e1f6397914006112595602.webp



02


什么是线程安全?


线程安全问题指的是多个线程之间对一个或多个共享可变对象交错操作时,有可能导致数据异常。


竞态条件(Race Condition)

计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。举个例子,线程A和线程B同时执行单例里的getInstance(),当线程A执行时发现getInstance()返回的是null,会立即创建单例对象,同时线程B执行结果也一样,也会创建一个单例对象。

竞态不一定导致计算结果的不正确,而是不排除计算结果有时正确有时错误的可能。和大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序。


线程原子性

线程原子性表示的是在共享变量的操作必须是功能聚合不可分的,必须要连续完成,举个例子,线程A里有一个对共享变量x++的操作,这个操作的流程应该是将共享内存中读取数据后,在线程内创建一个临时x变量,然后对临时x变量进行x++,最终将输出结果同步到共享区域的x变量内,这一系列的操作如果在中途有其他线程对变量a进行重新赋值,那么就没办法保证线程A对x变量操作是正确的,所以我们必须要保证线程操作共享变量必须是具有原子性的,如何保证线程原子性,在后面会讲到。

0f49955a7ec70d6e1499242831741341.webp

线程可见性

线程可见性指的是在多线程环境下如果某一个线程对共享变量的数据进行更新后,对于其他的线程访问这个共享变量是否为更新后的结果。


线程有序性

有序性指的是在程序执行的时候,代码执行的顺序和语句的顺序是一致的。出现线程无序是因为在Java内存环境下,允许编译器和处理器对指令进行重排序,虽然不会影响单线程的执行顺序,但是会影响到多线程并发的执行正确性。如何避免重排序,会在下文里提到。



03


如何实现线程安全?


要实现线程安全就必须要保证原子性、可见性和有序性。其中包括锁和原子类型。

线程锁

线程锁指的是在多线程环境下,某个线程要对共享内存里的数据进行操作时,先将其上锁,处理完成后,再进行解锁操作。举个例子,我们在现实生活中逛超市的时候需要把随身物品寄存在寄存箱里,然后把箱子上锁后开始逛超市,买完了东西后,解锁寄存箱来取随身物品,然后这个寄存箱就可以提供给别人使用了。寄存箱相当于共享区域的对象,而随身物品则是对象的值,寄存的操作由人来完成,人相当于线程。

09700c57dfd8026f3d06a73899314e46.webp


锁的特点

临界区

我们在给超市的寄存箱上锁后,将随身物品放入寄存箱然后去逛超市,直到逛完超市后解锁寄存箱取出随身物品,这个上锁和解锁的区间就是临界区。代码里的表现就是,持有锁的线程获取锁后和释放锁的执行操作,这个区间执行的代码叫做临界区。

串行

多线程开发环境下,有多个线程并行执行事务,当有锁的介入时,就相当于把多个线程串行执行,举个例子,你在使用寄存箱的时候,如果别人要使用,必须得排队等你解锁后才可以使用。


调度策略

锁的调度策略分为公平策略和非公平策略,对应的锁就叫公平锁和非公平锁。

公平锁

就是多线程按照申请锁的顺序,未申请到锁的线程会在线程等待队列里进行排队,只有队列首位才能拿到锁。

优点:所有线程都能得到资源,不会造成线程饥饿

缺点:增加了上下文切换的代价,增加了线程暂停和唤醒的操作,会造成吞吐量低,等待队列里会长时间阻塞大量未获取到锁的线程,CPU唤醒线程的开销也会变大。

非公平锁

在线程去获取对象锁的时候,会直接尝试获取,如果获取不到,再进入等待队列,如果能获取到,就直接获取锁。

优点:减少CPU唤醒线程的开销,吞吐量高

缺点:会导致等待队列中的某些检测长时间获取不到锁,造成线程饥饿


锁的问题

锁泄漏

锁泄漏指的是代码的错误可能导致一个线程在其执行完临界区代码之后未能释放引导这个临界区的锁,最终导致其他线程无法获取锁



04


内部锁


synchronized是Java提供的内部锁,里边有类锁和对象锁;在静态方法中,我们一般使用类锁,在实例方法中,我们一般使用对象锁。使用 synchronized 实现的线程同步是通过监视器(monitor)来实现的,所以内部锁也叫监视器锁。

内部锁的临界区

同步代码块就是内部锁的临界区,如果某一条线程需要执行同步代码块的事务,必须先持有此代码块也就是临界区的锁。

不会造成锁泄漏

锁泄漏上文有提过,内部锁不会导致锁泄漏,javac编译器把同步代码块编译成字节码时,对内部锁的同步代码块中的事务做了特殊处理,即使在代码执行异常,也不会导致锁的释放,所以不会造成锁泄漏。

非公平锁

内部锁的策略走的是非公平锁,也就是有可能会造成线程饥饿,但是不会增加线程上下文切换的开销


内部锁的基本用法:

Thread threadA = new Thread(new Runnable() {            @Override            public void run() {                lock1();            }        },"my-ThreadB");        threadA.start();
Thread threadB = new Thread(new Runnable() { @Override public void run() { lock2(); } },"my-ThreadA"); threadB.start();
private final String lockTest = "test";    private void lock1(){        Log.e("线程测试","threadA开始获取锁");        synchronized (lockTest){            Log.e("线程测试","threadA拿到内部锁,开始执行临界区事务");            try {                Thread.sleep(2000);            } catch (InterruptedException e) {                e.printStackTrace();            }            Log.e("线程测试","threadA临界区事务执行完成,释放锁");        }    }
private void lock2(){ Log.e("线程测试","threadB开始获取锁"); synchronized (lockTest){ Log.e("线程测试","threadB拿到内部锁,开始执行临界区事务"); } Log.e("线程测试","threadB临界区事务执行完成,释放锁"); }

程序执行结果:

ef6f17b6e17f9ef775e348fd89fff965.webp



05


显式锁


想做到线程同步,方案不止内部锁一种,而当内部锁不满足某些特定场景需求的时候,则可以选择使用显式锁使用更灵活的功能。

我们正常使用显式锁的操作是用Lock接口来实现,Lock接口对显式锁进行了抽象,ReentrantLock则是Lock接口的实现类。

ReentrantLock

296f4fc8f52f4af584f1bd95fde93074.webp

我们可以根据ReentrantLock的源码重载方法分析到,其实显式锁是支持公平/非公平锁,因为公平锁的上下文开销比较大,所以默认不做配置则是非公平策略

8d522b7a27119f691e8b3cf80fc64123.webp

显式锁的临界区

Lock接口提供了lock()与unlock()方法,代表的是锁定和解锁的操作,这两个方法间的代码块就是显式锁的临界区

锁泄漏

显式锁和内部锁不一样,如果操作不当会造成锁泄漏,所以必须要手动释放锁

显式锁的基本用法

private final String lockTest = "test";
private void lock1(){ Log.e("线程测试","threadA开始获取锁"); synchronized (lockTest){ Log.e("线程测试","threadA拿到内部锁,开始执行临界区事务"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } Log.e("线程测试","threadA临界区事务执行完成,释放锁");    }}
private void lock2(){ Log.e("线程测试","threadB开始获取锁"); synchronized (lockTest){ Log.e("线程测试","threadB拿到内部锁,开始执行临界区事务"); } Log.e("线程测试","threadB临界区事务执行完成,释放锁");}

显式锁执行结果:

d66ccd066776c63393e60aa64b84a916.webp

显式锁获取锁的方法

lock()

获取显式锁,如果获取成功则执行临界区的代码,获取失败则线程进入阻塞状态。

tryLock()

获取显式锁,获取成功后返回true,失败返回false,失败之后不会让线程进入阻塞状态。

基本使用方式如下:

//实例化Lock接口对象private final Lock lock = new ReentrantLock();//根据尝试获取锁的值来判断具体执行的代码if(lock.tryLock()) {     try{         //处理任务     }catch(Exception ex){     }finally{       //当获取锁成功时最后一定要记住finally去关闭锁         lock.unlock();   //释放锁     } }else {  //else时为未获取锁,则无需去关闭锁    //如果不能获取锁,则直接做其他事情}

tryLock(long time, TimeUnit unit)

是tryLock()的重载方法,功能一致,只不过参数可控在指定时间内没有获取到锁,才返回false。

lockInterruptibly()

与lock()方法区别在与lock方法是不可以通过Thread.interrupt来中断线程的,而lockInterruptibly()方法其他线程可以通过Thread.interrupt中断线程并且立即返回,简单来说该方法被调用后一直阻塞到获得锁 但是接受中断信号,而lock()不接受中断信号。



06


内部锁和显式锁的区别


灵活性

内部锁是基于代码的锁,锁的获取和释放都是在方法块里被动执行,缺乏灵活性。

显式锁是基于对象的锁,锁的获取和释放可以由开发者自定义控制,会更加灵活。

锁的调度策略

内部锁仅支持非公平锁

显式锁支持公平锁和非公平锁,开发者可以根据使用场景来控制

便利性

内部锁简单易用,锁泄漏的处理系统已经帮你处理好了,不需要额外投入精力去做释放操作。

显式锁需要手动获取和释放锁,在某种未考虑到的特定场景下,就有可能会造成锁泄漏。

阻塞

内部锁在获取锁的时候,如果获取不到,则让线程进入等待队列

显式锁接口提供了tryLock()的方法,如果获取不到锁,则直接返回false,不会导致线程阻塞。

适用场景

在多线程环境下如果临界区的事务耗时短则考虑使用内部锁

在多线程环境下如果临界区的事务耗时长则考虑使用显式锁



总结

因篇幅原因本文主要是介绍了一部分线程的运行环境、线程安全性的基础理论以及锁的相关知识,请大家及时关注《Java线程安全(下)》,下文会将到读写锁、volatile、原子类型、线程活跃性、死锁、锁死、线程间的安全协作等。


感谢您的阅读,祝您工作顺利81cba823e8ba9e0beb75348cd0c5e712.webp










浏览 40
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报