Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)

Posted CodeJiao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)相关的知识,希望对你有一定的参考价值。

文章目录

1. 并发编程线程基础(下篇)

并发编程线程基础(上篇)
并发编程线程基础(下篇)


1.1 什么是多线程并发编程

并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,而并行是说在单位时间内多个任务同时在执行。并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。在单CPU的时代多个任务都是并发执行的,这是因为单个CPU同时只能执行一个任务。在单CPU时代多任务是共享一个CPU的,当一个任务占用CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完后,会把CPU让给其他任务来使用,所以在单CPU时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。

下图所示为在单个CPU上运行两个线程,线程A和线程B是轮流使用CPU进行任务处理的,也就是在某个时间内单个CPU只执行一个线程上面的任务。当线程A的时间片用完后会进行线程上下文切换,也就是保存当前线程A的执行上下文,然后切换到线程B来占用CPU运行任务。


1.2 为什么要进行多线程并发编程

多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个线程可以使用自己的CPU运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。


1.3 Java中的线程安全问题

谈到线程安全问题,我们先说说什么是共享资源。所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。

在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成线程安全问题的潜在原因。

在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。


1.4 Java中共享变量的内存可见性问题

谈到内存可见性,我们首先来看看在多线程下处理共享变量时Java的内存模型,如图所示:

Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。

一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存(私有内存),然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

内存不可见问题

  • 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到。
  • 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。

1.5 Java中的synchronized关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。

synchronized的内存语义:

前面介绍了共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来讲解synchronized的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。


1.6 Java中的volatile关键字

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

volatile虽然提供了可见性保证,但并不保证操作的原子性。


1.6.1 volatile关键字使用场景

  • 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为volatile的。

1.6.2 volatile关键字原理

volatile可以保证共享变量的可见性和有序性。

保证可见性:

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
    public void actor2(I_Result r) 
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
    public void actor1(I_Result r) 
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if (ready) 
            r.r1 = num + num;
         else 
            r.r1 = 1;
        
    

保证有序性:

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    public void actor2(I_Result r) 
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
    public void actor1(I_Result r) 
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if (ready) 
            r.r1 = num + num;
         else 
            r.r1 = 1;
        
    

1.7 Java中的原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。(这样就要求在执行原子性操作的时候不可以发生上下文切换


1.8 Java中的CAS操作

在Java中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开销。Java提供了非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程度上弥补了锁带来的开销问题,但是volatile只能保证共享变量的可见性,不能解决原子性。CAS即Compare and Swap,其是JDK提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。JDK里面的Unsafe类提供了一系列的compareAndSwap*方法,下面以compareAndSwapLong方法为例进行简单介绍。

  • boolean compareAndSwapLong(Object obj, long valueOffset, longexpect, long update)方法:其中compareAndSwap的意思是比较并交换。CAS有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect。这是处理器提供的一个原子性指令。

关于CAS操作有个经典的ABA问题,具体如下:假如线程I使用CAS修改初始值为A的变量X,那么线程I会首先去获取当前变量X的值(为A),然后使用CAS操作尝试修改X的值为B,如果使用CAS操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程I获取变量X的值A后,在执行CAS前,线程II使用CAS修改了变量X的值为B,然后又使用CAS修改了变量X的值为A。所以虽然线程I执行CAS时X的值是A,但是这个A已经不是线程I获取时的A了。这就是ABA问题。

ABA问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从A到B,然后再从B到A。如果变量的值只能朝着一个方向转换,比如A到B, B到C,不构成环形,就不会存在问题。JDK中的AtomicStampedReference类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题的产生。


1.9 Unsafe类


1.9.1 Unsafe类中的重要方法

JDK的rt.jar包中的Unsafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,它们使用JNI的方式访问本地C++ 实现库。下面我们来了解一下Unsafe提供的几个主要的方法以及编程时如何使用Unsafe类做一些事情。

  • long objectFieldOffset(Field field)方法:返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该Unsafe函数中访问指定字段时使用。如下代码使用Unsafe类获取变量value在AtomicLong对象中的内存偏移。
  • int arrayBaseOffset(Class arrayClass)方法:获取数组中第一个元素的地址。
  • int arrayIndexScale(Class arrayClass)方法:获取数组中一个元素占用的字节。
  • boolean compareAndSwapLong(Object obj, long offset, longexpect, long update)方法:比较对象obj中偏移量为offset的变量的值是否与expect相等,相等则使用update值更新,然后返回true,否则返回false。
  • public native long getLongvolatile(Object obj, long offset)方法:获取对象obj中偏移量为offset的变量对应volatile语义的值。
  • void putLongvolatile(Object obj, long offset, long value)方法:设置obj对象中offset偏移的类型为long的field的值为value,支持volatile语义。
  • void putOrderedLong(Object obj, long offset, long value)方法:设置obj对象中offset偏移地址对应的long型field的值为value。这是一个有延迟的putLongvolatile方法,并且不保证值修改对其他线程立刻可见。只有在变量使用volatile修饰并且预计会被意外修改时才使用该方法
  • void park(boolean isAbsolute, long time)方法:阻塞当前线程,其中参数isAbsolute等于false且time等于0表示一直阻塞。time大于0表示等待指定的time后阻塞线程会被唤醒,这个time是个相对值,是个增量值,也就是相对当前时间累加time后当前线程就会被唤醒。如果isAbsolute等于true,并且time大于0,则表示阻塞的线程到指定的时间点后会被唤醒,这里time是个绝对时间,是将某个时间点换算为ms后的值。另外,当其他线程调用了当前阻塞线程的interrupt方法而中断了当前线程时,当前线程也会返回,而当其他线程调用了unPark方法并且把当前线程作为参数时当前线程也会返回。
  • void unpark(Object thread)方法:唤醒调用park后阻塞的线程。

下面是JDK8新增的函数,这里只列出Long类型操作。

  • long getAndSetLong(Object obj, long offset, long update)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量volatile语义的值为update。

  • long getAndAddLong(Object obj, long offset, long addValue)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量值为原始值+addValue。


1.9.2 如何使用Unsafe类


在如上代码中:

  1. 代码(2.2.1)获取了Unsafe的一个实例
  2. 代码(2.2.3)创建了一个变量state并初始化为0
  3. 代码(2.2.4)使用unsafe.objectFieldOffset获取TestUnSafe类里面的state变量,在TestUnSafe对象里面的内存偏移量地址并将其保存到stateOffset变量中
  4. 代码(2.2.6)调用创建的unsafe实例的compareAndSwapInt方法,设置test对象的state变量的值。具体意思是,如果test对象中内存偏移量为stateOffset的state变量的值为0,则更新该值为1。

运行上面的代码,我们期望输出true,然而执行后会输出如下结果:

为找出原因,必然要查看getUnsafe的代码:

代码(2.2.7)获取调用getUnsafe这个方法的对象的Class对象,这里是TestUnSafe. class。

代码(2.2.8)判断是不是Bootstrap类加载器加载的localClass,在这里是看是不是Bootstrap加载器加载了TestUnSafe.class。很明显由于TestUnSafe.class是使用AppClassLoader加载的,所以这里直接抛出了异常。

我们知道Unsafe类是rt.jar包提供的,rt.jar包里面的类是使用Bootstrap类加载器加载的,而我们的启动main函数所在的类是使用AppClassLoader加载的,所以在main函数里面加载Unsafe类时,根据委托机制,会委托给Bootstrap去加载Unsafe类。如果没有代码(2.2.8)的限制,那么我们的应用程序就可以随意使用Unsafe做事情了,而Unsafe类可以直接操作内存,这是不安全的,所以JDK开发组特意做了这个限制,不让开发人员在正规渠道使用Unsafe类,而是在rt.jar包里面的核心类中使用Unsafe功能。

如果开发人员真的想要实例化Unsafe类,那该如何做?使用万能的反射来获取Unsafe实例方法。

运行结果:


1.10 Java指令重排序

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。

重排序在多线程下会导致非预期的程序执行结果,而使用volatile修饰共享变量就可以避免重排序和内存可见性问题。

volatile修饰共享变量,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。


1.11 伪共享


1.11.1 什么是伪共享

为了解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或者多级高速缓冲存储器(Cache)。这个Cache一般是被集成到CPU内部的,所以也叫CPU Cache,下图所示是两级Cache结构:

在Cache内部是按行存储的,其中每一行称为一个Cache行。Cache行是Cache与主内存进行数据交换的单位,Cache行的大小一般为2的幂次数字节。

当CPU访问某个变量时,首先会去看CPU Cache内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存复制到Cache中。由于存放到Cache行的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享


1.11.2 如何避免伪共享

在JDK 8之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,例如如下代码。
假如缓存行为64字节,那么我们在FilledLong类里面填充了6个long类型的变量,每个long类型变量占用8字节,加上value变量的8字节总共56字节。另外,这里FilledLong是一个类对象,而类对象的字节码的对象头占用8字节,所以一个FilledLong对象实际会占用64字节的内存,这正好可以放入一个缓存行。

JDK 8提供了一个sun.misc.Contended注解,用来解决伪共享问题。将上面代码修改为如下。

在这里注解用来修饰类,当然也可以修饰变量,比如在Thread类中:

Thread类里面这三个变量默认被初始化为0,这三个变量会在ThreadLocalRandom类中使用。

需要注意的是,在默认情况下,@Contended注解只用于Java核心类,比如rt包下的类。如果用户类路径下的类需要使用这个注解,则需要添加JVM参数:-XX:-RestrictContended。填充的宽度默认为128,要自定义宽度则可以设置-XX:ContendedPaddingWidth参数。


1.12 锁的概述

Java中的锁机制


1.12.1 乐观锁与悲观锁

乐观锁:

乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前与期望 值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。

Java中的乐观锁CAS(Compare And Set),比较并替换,比较当前值(主内存中的值),与预期值(当前线程中的值,主内存中值的一份拷贝)是否一样,一样则更新,否则继续进行CAS操作。

如上图所示,乐观锁可以同时进行读操作,读的时候其他线程不能进行写操作

悲观锁:

悲观锁是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的 时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程block,直到这个线程释放锁然后其他线程获取到锁。

Java中的悲观锁synchronized修饰的方法和方法块、ReentrantLock锁。

如上图所示,只能有一个线程进行读操作或者写操作,其他线程的读写操作均不能进行(被阻塞)。


1.12.2 公平锁与非公平锁

公平锁:


公平锁是一种思想: 多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不 为空,则加入到等待队列的末尾,按照FIFO的原则从队列中拿到线程,然后占有锁。

非公平锁:

非公平锁是一种思想: 线程尝试获取锁,如果获取不到,则再采用公平锁的方式。多 个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁

  • 优点: 非公平锁的性能高于公平锁。
  • 缺点: 有可能造成线程饥饿(某个线程很长一段时间获取不到锁)
  • Java中的非公平锁synchronized是非公平锁,ReentrantLock通过构造函数指定该锁是公平的(ReentrantLock(true))还是非公平的(ReentrantLock(false)),默认是非公平的。

1.12.3 独占锁(悲观锁的一种)与共享锁(乐观锁的一种)

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。

独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。


1.12.4 可重入锁


可重入锁是一种技术: 任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。

可重入锁的原理: 通过组合自定义同步器来实现锁的获取与释放。

  • 再次获取锁:识别获取锁的线程是否为当前占据锁的线程,如果是,则再 次成功获取。获取锁后,进行计数自增。
  • 释放锁:释放锁时,进行计数自减。

Java中的可重入锁ReentrantLocksynchronized修饰的方法或代码段。

可重入锁的作用避免死锁

可重入锁常见问题:

:可重入锁如果加了两把,但是只释放了一把会出现什么问题?
:程序会卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。

:如果只加了一把锁,释放两次会出现什么问题?
:会报错,抛出java.lang.IllegalMonitorStateException异常。


1.12.5 自旋锁

自旋锁是一种技术: 为了让线程等待,我们只须让线程执行一个忙循环(自旋)。 现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个 以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

  • 自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核 态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。
  • 自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源, 而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
  • 自旋次数默认值10次,可以使用参数-XX:PreBlockSpin来自行更改。
  • 自适应自旋: 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长 及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
  • Java中的自旋锁CAS操作中的比较操作失败后的自旋等待。


以上是关于Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)的主要内容,如果未能解决你的问题,请参考以下文章

4.java并发编程艺术-java并发编程基础

Java并发编程系列之二线程基础

并发编程总结——java线程基础1

Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)

Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)

Java并发编程的艺术读书笔记——Java并发编程基础