美文网首页
线程安全,内存可见性,竞态条件,内置锁,显式锁概述

线程安全,内存可见性,竞态条件,内置锁,显式锁概述

作者: 旋转马达 | 来源:发表于2018-06-23 23:10 被阅读0次

    线程安全,内存可见性,竞态条件,内置锁,显式锁概述

    在Java中按照锁的实现方式可以划分为内置锁和显式锁,内置锁有JVM提供语法层面的支持,显式锁则有jdk提供的API来支持。也许你会感到奇怪,线程或者锁在并发编程中的作用,类似于铆钉和工字梁在宫殿建筑中的作用一样,要构建一座稳定,高质量的宏伟宫殿,必须正确的使用大量的铆钉和工字梁,同样的道理,要构建正确的,安全的,高效的高并发程序必须正确的合理的使用锁和线程。

    线程安全

         正确的使用线程和锁是构建安全的并发程序的基础,但是这种终归只是一些手段,而其核心思想则在于对共享的 & 可变的 资源访问操作进行管理,共享意味着可以同时被多个线程访问,可变则意为着变量的值在生命周期内可以随时发生变化,我们将这些共享的和可变的资源称之为对象的状态。因为线程对资源不同的时候的不同操作,导致对象有不同的状态,我们要对这种状态的变化进行管理控制,使其始终处于一致的状态,至于什么是一致,则由一致性条件来决定,一致性条件由程序的开发者根据业务上下文来决定什么是一致,什么是不一致,总的来说就是对象的各部分在从一个状态到另一个状态结束的时候逻辑上都是正确的,这里不区分最终一致还是强一致。在某种意义上你可以将存储在成员变量或者静态的域中的数据当做是对象的状态。资源的访问操作就是针对他们来执行的操作。
         经过以上讨论我们可以对线程安全给出一个定义:当多个线程访问某个类的时候,这个类始终表现出正确的行为,那么就称这个类是线程安全的。至于什么行为是正确的那就要看你是怎么定义一致性条件的了。如果这个行为带来的资源变化符合你定义的一致性条件,那么这个行为就是正确,反之则是错误的。一个对象是否需要是线程安全的,取决于他是否被多个线程访问。可以说线程安全不是一个功能,只是一种对对象包含的资源的一种访问方式,对象完全可以以非线程安全的方式访问。

         当多个线程访问某个可以引起对象状态改变的变量(下文成为状态变量)的时候,如果其中至少有一个线程执行写入操作,那么必须采用同步机制来协同这些线程对状态变量的访问,当然了如果都是读取,那么完全可以不用同步控制,多个线程可以随意读取。因为只要这些变量不会改变,那么永远读取到的数据都是正确的。所以不可变的对象永远是线程安全的,因为他包含的变量不会改变。Java的主要同步机制是关键字synchronized,他提供了一种独占的加锁方式,实现同步还有别的方式,比如volatile类型的变量和显式锁,以及原子变量。

         在一些大型程序中,要找出多个线程在那些未知上将访问同一个变量是非常复杂的,但幸运的是,面向对象这种技术不仅有助于编写出结构优雅,可维护性高的类,还有助于编写出线程安全的类,直接访问 某个变量的地方越少,就越容易实现对变量的所有访问都正确的同步,同时也更容易找出变量在那些条件下被访问。Java虽然完全不要求开发人员将状态都封装在类中,完全可以将状态保存到一个全局的静态变量中,然而这样是很不安全的,程序的封装性越强,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这个类的安全性。所以平时开的时候一定要合理使用Java语言提供的封装性。(重要)

    竞态条件(Race condition)

         在并发编程中,由于不恰当的执行时序导致出现不正确的结果是一种非常重要的情况,这种情况有一种正是的名字叫做竞态条件,由于多个线程的访问,执行时序的不同导致出现某种不正常的行为,这已经违背了线程安全性,这是线程安全中最常见的一类问题,我们编程时不应该假设此类问题不会发生,他经常性的发生在我们身边。大多数竞态条件的本质是基于一种可能失效的观察结果来做出判断是否执行某个计算,最常见的当属先检查,后执行,比如

    public class LazyInitRace{
        private ExpensiveObject instance ;
        public ExpensiveObject getInstance(){
            if(instance == null){
                instance = new ExpensiveObject();
            }
            return instance;
        }
      
    }
    

    由于CPU是时分复用的,给每个线程分配时间片执行,如果A线程进入if语句之后执行对象的初始化,但因为初始化过程并不是原子的,会有很多步骤,在初始化还没有完成的时候线程被挂起,此时B线程获得时间片执行,在检查的时候发现instance还是为null,开始执行初始化,这样一来就会执行多次的初始化,每个线程持有的instance不一样, 就出现了竞态条件。

    避免竞态条件

         要避免竞态条件就必须在某个线程修改该变量的时候通过某种方式阻止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或者之后读取和修改数据,而不是在正在修改的过程中。
          原子性:假设有两个操作A和B,从A的角度看,当线程执行B的时候,要么B完全执行完毕,要么B不执行,那么B相对于A就是原子性的。原子操作就是访问类的同一个状态的多个操作按照原子性的方式执行,那么这就是原子操作,Java中的内置锁就是用于确保原子性的内置机制,他可以将一组复合操作按照原子的方式执行,为了确保线程安全性,类似“先检查,后执行”,“读取-修改-写入”等操作必须是原子的,因为在操作执行期间对象处于一个不一致的中间状态,如果此时操作被停止,转而执行其他操作来修改数据,那么就会出现线程安全问题————类表现出了不正常的行为,其中有一方的修改被丢失了。类似“先检查,后执行”,“读取-修改-写入”等操作我们称之为复合操作,包含了一组必须以原子方式执行的操作以确保线程安全性。后面我们会看到很多Java的并发API采用了这种复合思想,使得一组操作以原子的方式执行。

    内存可见性

         安全性关注的是糟糕的情形不会发生,比如使用同步防止某个线程修改一个正在被其他线程使用的对象,而内存可见性则关注的是修改的结果的可见,比如使用同步确保一个线程修改了对象的状态之后,其他线程都能够看到发生的状态变化,如果没有同步,这种情况无法实现。现代CPU的指令重排也会导致读写操作不在同一个线程的内存可见性问题,同步代码块的语义不仅仅是互斥和原子执行,还包括了内存可见性。为了确保所有线程都能看到共享变量的最新的值,所有执行读或写的线程都必须在同一个锁上进行同步。
         Java内存模型规定读操作和写操作必须是原子的,但是存在一个例外,非volatile类型的64位数值变量(long & double),在32位机器上,JVM允许将其分解为两个32位的操作,当读取一个一个非volatile类型的long型变量时,如果读操作和写操作不在同一个线程,由于调度顺序,可能会读取到某个值的高32位和另一个值的低32位,即使不考虑数据被其他线程修改了但是读线程读取到了旧的数据这种情况,在并发程序中使用long和double也是不安全的,除非使用volatile来声明他们,或者用锁保护起来。
         同步代码块可以用于保证某个线程以一种可以预测的方式执行代码,即顺序执行,然后当另一个线程执行同步代码块的时候可以看到上一个线程的所有操作结果。

    内置锁

         内置锁很容易理解,Java中每个对象都有一把锁,并提供了synchronized关键字来获取锁,而锁的释放则是在synchronized修饰的同步代码块执行完毕之后自动释放,至于锁的竞争和等待则是由锁池和等待池完成。可以用内置锁构建的同步代码块保护对对象的状态的访问。达到线程安全的特性,不过后面我们会分析,这种方式其实是很低效的,处在真正有必要的时候才使用功能这种方式来实现同步,否则我们应该选择更灵活,能够增加吞吐量的方式实现线程安全,实现状态访问控制。

    显式锁

         在Java5之前,在协调对对象的状态访问方面,可用的机制只有synchronized和volatile,Java5出现了一种新的机制——显式的锁,由java.util.concurrent.locks.Lock接口提供,主要的实现类是java.util.concurrent.locks.ReentrantLock,他的目标并非代替Java的内置锁,他只是在内置锁不适合的时候作为一种可选的高级功能。Lock接口提供了一种无条件的,可轮训的,定时的,以及可中断的锁获取操作,加锁和解锁都是显式的,他的实现类提供了和内置锁相同的内存可见性。但是其他方面则可能有所不同。比如加锁的语义不一定是获取对象的锁,还有调度算法,顺序保证以及性能方面等等。

    相关文章

      网友评论

          本文标题:线程安全,内存可见性,竞态条件,内置锁,显式锁概述

          本文链接:https://www.haomeiwen.com/subject/uiodyftx.html