Java并发编程系列|多线程并发编程进阶

阅读 10

05-24 21:00

今天开始学习Java并发编程系列的第二篇内容——多线程并发编程进阶。本篇内容将介绍synchronized、volatile关键字、Unsafe类、锁、和伪共享等内容。

01

synchronized关键字


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

假如有两个线程,A和B,A线程使用了放在主内存中的变量x,并修改x=1,然后在工作内存中缓存了一份;B线程读取变量x,B线程读取到的x是A线程修改后的x;这时B将x修改为了2,然后回刷主内存,这时x=2;但如果A读取x,A线程读取到的x还是1,因为他读的是自己的工作内存中缓存的变量。这就是内存可见性问题,即线程无法修改其他线程工作内存中的变量

Java中共享变量的内存可见性问题是指多个线程访问同一个变量时,由于Java虚拟机对线程的工作内存的缓存机制,导致线程间无法共享变量。

Java中共享变量的内存可见性问题可以通过synchronized关键字来解决。

synchronized块是Java提供的一种原子性内置锁,Java中每个对象都可以把它作为同步锁使用。这种锁称为内置锁或者监视器锁。线程的执行代码在进入synchronized块前会自动获取内部锁,这时其他线程无法进入该块,会被阻塞挂起。内置锁是排他锁。

synchronized的使用会导致上下文切换,上下文切换是指线程从运行状态变为就绪或等待状态。synchronized块的效率低,因为每次线程执行代码前都要获取内部锁,然后再释放内部锁。当阻塞一个线程时,需要从用户态切换到内核态,这也很耗时。

进入synchronized块的内存语义是把在synchronized块内使用的变量从线程的工作内存中清除,这样synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是从主内存中获取。离开synchronized块的内存语义是把线程的栈中修改的变量写到主内存中。

02

volatile关键字



内存不可见问题的第二种方式:volatile 关键字该关键字可以确保对一个变量的更新对其他线程马上可见

当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和 synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块 (先清空本地内存变量值,再从主内存获取最新值)。

public class ThreadSafeTest {
    private int value;


    public synchronized int get() {
        return value;
    }


    public synchronized void set(int value) {
        this.value = value;
    }
}

public class ThreadSafeTest {
    private volatile int value;


    public int get() {
        return value;
    }


    public void set(int value) {
        this.value = value;
    }
}

volatile可以避免指令重排序

volatile没办法实现读-改-写的原子性:

  1. 非原子操作:原子操作是指一个操作要么完全成功,要么完全失败,而不会被其他线程干预。

对于复合操作,比如 i++、i += 5 等,这些操作都会涉及到多个步骤:读取变量的当前值、进行计算、然后写回更新后的值。在这个过程中,如果其他线程也对该变量进行操作,就可能导致数据不一致。

  1. 只保证可见性,非原子性:

volatile变量可以确保一个线程对变量的写入对其他线程是可见的(即更新后的值会立刻反映在其他线程中),但并不为对该变量的读/写操作本身提供原子性。

03

Unsafe类


Unsafe类位于sun.misc.Unsafe包内。

Unsafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是 native方法,它们使用JNI的方式访问本地C++实现库。

常用方法:

static final Unsafe unsafe = Unsafe.getUnsafe();
public static void main(String[] args) throws NoSuchFieldException {
    unsafe.objectFieldOffset(String.class.getDeclaredField("value")); // 获取对象属性偏移量
    unsafe.arrayBaseOffset(String[].class); // 获取数组中第一个元素的地址
    unsafe.arrayIndexScale(String[].class); // 获取数组中一个元素占用的字节
    unsafe.compareAndSwapLong(new int[1], 0, 0, 1); // 比较obj对象中偏移量为offset的变量的值是否与except相等,相等则使用update值更新,然后返回true,否则返回false
    unsafe.getLongVolatile(new int[1], 0); // 获取obj对象中偏移量为offset的变量的值
    unsafe.putLongVolatile(new int[1], 0, 1); // 设置obj对象中偏移量为offset的变量的值为value
    unsafe.park(false, 1000); // 阻塞当前线程,当isAbsolute为false且time=0表示一直阻塞
    unsafe.unpark(Thread.currentThread()); // 唤醒线程
}


import sun.misc.Unsafe;


public class UnsafeTest {
    static final Unsafe unsafe = Unsafe.getUnsafe();
    private volatile long state = 0;
    // 记录state在UnsafeTest类中的偏移值
    static final long stateOffset;
    static {
        try {
            // 使用objectFieldOffset方法获取UnsafeTest类中的state变量的内存偏移量地址并保存在stateOffset中
            stateOffset = unsafe.objectFieldOffset(UnsafeTest.class.getDeclaredField("state"));
        } catch (NoSuchFieldException e) {
            System.out.println(e.getLocalizedMessage());
            throw new Error(e);
        }
    }
    public static void main(String[] args) {
        UnsafeTest unsafeTest = new UnsafeTest();
        // 如果unsafeTest的内存偏移量为stateOffset的state变量的值为0,则更新为1
        boolean b = unsafe.compareAndSwapInt(unsafeTest, stateOffset, 0, 1);
        System.out.println(unsafeTest.state);
        System.out.println(b);
    }
}


我们知道Unsafe类是rt.jar包提供的,rt.jar包里面的类是使用Bootstrap类加载器加载的,而我们的启动main函数所在的类是使用AppClassLoader加载的,所以在main函数里面加载Unsafe类时,根据委托机制,会委托给Bootstrap去加载Unsafe类。

import sun.misc.Unsafe;


import java.lang.reflect.Field;


public class UnsafeTest {
    static final Unsafe unsafe;
    private volatile long state = 0;
    // 记录state在UnsafeTest类中的偏移值
    static final long stateOffset;
    static {
        try {
            // 使用反射获取Unsafe的成员变量theUnsafe
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            // 设置field可访问
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);
            // 使用objectFieldOffset方法获取UnsafeTest类中的state变量的内存偏移量地址并保存在stateOffset中
            stateOffset = unsafe.objectFieldOffset(UnsafeTest.class.getDeclaredField("state"));
        } catch (NoSuchFieldException e) {
            System.out.println(e.getLocalizedMessage());
            throw new Error(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
    public static void main(String[] args) {
        UnsafeTest unsafeTest = new UnsafeTest();
        // 如果unsafeTest的内存偏移量为stateOffset的state变量的值为0,则更新为1
        boolean b = unsafe.compareAndSwapInt(unsafeTest, stateOffset, 0, 1);
        System.out.println(unsafeTest.state);
        System.out.println(b);
    }
}



04


  • 乐观锁:乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
  • 悲观锁:悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线 程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
  • 公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。

ReentrantLockpairLock =new ReentrantLock(true)

  • 非公平锁:在运行时闯入,也就是先来不一定先得。

ReentrantLockpairLock=newReentrantLock(false)

  • 独占锁:独占锁保证任何时候都只有一个线程能得到锁, ReentrantLock 就是以独占方式实现的。独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
  • 共享锁:共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作 。共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
  • 可重入锁:当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己己经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(在后面我们将知道,严格来说是有限次数)地进入被该锁锁住的代码 。

synchronized 内部锁是可重入锁 。可重入锁的原理是在锁内部维护一个线程标示示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0, 说明该锁没有被任何线程占用 。当一个钱程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取 该锁时会发现锁的所有者不是自己而被阻塞挂起。

但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+ 1, 当释放锁后计数器值 -1。当计数器值为0时,锁里面的线程标示被重置为null, 这时候被阻塞的线程会被唤醒来竞争获取该锁 。

  • 自旋锁:当前线程在获取锁时,如果发现锁已经被其他线程占有, 它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是 10,可以使用-XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程己经释放了锁 。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起 。

05

伪共享


为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在CPU 与主内存之间添加一级或者多级高速缓冲存储器(Cache)。这个Cache一般是被集成到 CPU 内部的, 所以也叫 CPU Cache,在 Cache 内部是按行存储的,其中每一行称为一个 Cache行。Cache 行的大小一般为2的幂次数字节。

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

伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量 。那么为何多个变量会被放入一个缓存行呢?其实是因为缓存与内存交换数据的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存放入缓存行。

在 JDK 8 之前 一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同 一个缓存行中,JDK 8提供了一个sun.misc.Contended注解,用来解决伪共享问题。

@sun.misc.Contended

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

精彩评论(0)

0 0 举报