锁的理论

37

可重入锁

可重入锁也可以被称为递归锁,指的是同一线程外层代码块获得锁之后,如果锁对象是同一个对象时 进入内层代码块自动获取该锁.可重入锁可以避免死锁

synchronized

synchronized就是一种可重入锁,线程可以进入任何一个它已经拥有的锁所同步着的代码块

同一个线程调用不同的synchronized方法只需要一把锁

如果目标对象头的计数器为0,说明它没有被其他线程所持有,虚拟机会将该锁对象持有的线程设置为当前线程,并且将计数器+1.在目标锁对象的计数器不为0的情况下,如果锁对象持有线程时当前线程,那么虚拟机可以将其计数器+1,否则等待直至持有线程释放锁

final static Object objectA = new Object();
public static void m1(int i) {
    new Thread(() -> {
        String threadName = Thread.currentThread().getName();
        synchronized (objectA) {
            System.out.println(threadName + "outer");
            synchronized (objectA) {
                System.out.println(threadName + "inner");
            }
        }
    }, Integer.toString(i)).start();
}
// 可以看到每个线程获取外部的锁自动获得内部的锁
// 并且其他线程被堵塞在最外部
public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        m1(i);
    }
}

如果 synchronized 不是可重入锁,线程在 method1 中持有了 lock 锁后,再次进入 method2 时会尝试再次获取 lock 锁.由于 lock 锁已经被当前线程持有,根据不可重入锁的假设,线程会在 method2 中无法获取 lock 锁,从而导致线程被阻塞

public void method1() {
    synchronized (lock) {
        method2();
    }
}
public void method2() {
    synchronized (lock) {
    }
}

ReentrantLock

ReentrantLocks也是一种可重入锁,不过注意synchronized是通过代码块的形式加锁和解锁,由JVM来管理,但是当使用ReentrantLock加锁解锁不匹配会导致死锁

new Thread(() -> {
    try {
        lock.lock();
        try {
            lock.lock();
        }finally {
            // lock.unlock(); // 如果注释改行会导致另一个线程迟迟无法获得锁,导致死锁
        }
    } finally {
        lock.unlock();
    }
}).start();
​
new Thread(() -> {
    try {
        lock.lock(); // 此处死锁
        try {
            lock.lock();
        }finally {
            lock.unlock();
        }
    } finally {
        lock.unlock();
    }
}).start();

公平锁

非公平锁

多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁.在高并发的情况下有可能会造成一个线程长时间获取不到锁

直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式

ReentrantLock lock = new ReentrantLock();

也可以使用synchronized关键字获取非公平锁

public synchronized void lock() {}

公平锁

锁被释放之后,多个线程按照申请锁的顺序来获取锁.性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁

每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己

// ReentrantLock获取公平锁
ReentrantLock lock = new ReentrantLock(true);

自旋锁

尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

CompareAndSwap是一条CPU并发原语,判断内存某个位置的值是否为期望值,符合则更改为新的值,这个过程是原子的

比较并交换

如果线程的期望值和主物理内存的真实值一样就修改为更新值,否则则需要重新获得主物理内存的真实值

AtomicInteger atomicInteger = new AtomicInteger(5);
// 自增
atomicInteger.getAndIncrement(); 
// 如果atomicInteger为6,则赋值为1024,赋值成功返回true
boolean flag = atomicInteger.compareAndSet(6, 1024); 

源码

AtomicInteger源码

// Unsafe类
private static final Unsafe unsafe = Unsafe.getUnsafe();
// value实际存储的值,volatile修饰对其它线程可见的
private volatile int value;
​
// 偏移地址
private static final long valueOffset;
static {
    try {
        // 获取valueOffset偏移地址(内存地址),Unsafe就是根据内存偏移地址获取数据
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}
​
public final int getAndIncrement() {
    // Unsafe通过偏移地址获取当前对象this的内存数据,进行+1操作
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

Unsafe类

CAS是一种系统原语,由若干指令组成,原语的执行必须是连续的,在执行过程中不允许被打断,也就是CAS是CPU层面的原子指令不会造成数据不一致的问题

Unsafe是CAS的核心类,由于java无法直接访问底层,则需要通过native方法来方法,Unsafe相当于一个后门,其内部可以像C的指针直接操作内存

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        // 获取对象o在内存地址offset的最新值
        v = getIntVolatile(o, offset);
        // 获取对象o在内存地址offset的值和v的值是否一致,一致+1
        // (对比主物理内存和本地内存的值)
        // 直至判断一致,否则一致循环
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}
​
public native int getIntVolatile(Object o, long offset);
public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);

简而言之就是执行getIntVolatile和compareAndSwapInt之间,可能会被其他线程修改,所以需要加锁,直到主物理内存和本地内存的值一致才修改

缺点及解决方案

  1. 失败会一直尝试,如果长时间不成功会给CPU造成非常大的执行开销

LongAdder使用了一种称为分散累加的策略,通过将加法操作分散到多个独立的变量cell上,避免了多线程竞争的情况.每个线程都可以独立地对cell进行加法操作,最后通过对所有"cell"的求和得到最终结

LongAdder适用于高并发环境,可以提供更好的性能

LongAdder longAdder = new LongAdder();
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(() -> {
        for (int j = 1; j < 100; j++) {
            longAdder.add(4);
        }
    }, "t1");
    threads[i].start();
}
// thread.join等待执行完成后,输出结果
System.out.println("Sum: " + longAdder.sum());
  1. CAS 操作仅能对单个共享变量有效

当需要操作多个共享变量时可以使用AtomicReference类,这使得我们能够保证引用对象之间的原子性.通过将多个变量封装在一个对象中,并通过AtomicReference来执行CAS操作

@AllArgsConstructor
class User {
    String userName;
    int age;
}
​
public class AtomicReferenceDemo {
    public static void main(String[] args) {
        AtomicReference<User> atomicReference = new AtomicReference<>();
        User z3 = new User("z2", 22);
        User li4 = new User("li4", 25);
        atomicReference.set(z3);
        // 期望值是z3,比较成功后修改为li4
        System.out.println(atomicReference.compareAndSet(z3, li4) + atomicReference.get());
    }
}
  1. ABA问题

ABA问题

CAS算法实现的重要前提是需要取出内存中某个时刻的数据并在当前比较并替换,这个时间差会导致数据变化

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,也无法说明它的值没有被其他线程修改过.因为在这段时间它的值可能被改为其他值,然后又改回 A,CAS操作就会误认为它从来没有被修改过

尽管CAS最终操作成功,但是不代表这个过程就不存在问题

带戳记的原子引用AtomicStampedReference

// 初始值和默认的戳记
AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 0);

如果参考戳记和实际戳记一致才修改

// 是否修改成功
boolean result = atomic.compareAndSet(100, 109, stamp, stamp + 1);
// 获取引用类型
Integer reference = atomic.getReference();

ABA问题模拟并解决

AtomicStampedReference<Integer> atomic = new AtomicStampedReference<>(100, 0);
​
new Thread(() -> {
    int stamp = atomic.getStamp();
    // 模拟费时操作
    ThreadUtil.sleep(5000);
    // 虽然值被修改会100,但是版本号不一致修改失败
    boolean result = atomic.compareAndSet(100, 109, stamp, ++stamp);
    System.out.println(result);
}).start();
​
new Thread(() -> {
    int stamp = atomic.getStamp();
    atomic.compareAndSet(100, 101, stamp, ++stamp);
    atomic.compareAndSet(101, 100, stamp, ++stamp);
}, "t3").start();

共享锁

读写锁

上述的锁都是独占锁,意思指只能被一个线程持有.而共享锁一把锁可以被多个线程同时获得

由于ReentrantReadWriteLock既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全.所以在读多写少的情况下,使用ReentrantReadWriteLock能够明显提升系统性能

读读不互斥、读写互斥、写写互斥

写操作必须原子+独占

// 一个读写融为一体的锁,在使用的时需要转换
// 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来指定
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
​
rwLock.writeLock().lock();
// 进行写操作
rwLock.writeLock().unlock();
​
rwLock.readLock().lock();
// 进行读操作
rwLock.readLock().unlock();

写入操作是一个一个线程进行执行的,并且中间不会被打断,而读操作的时候多个个线程进入并发读取操作

死锁问题

死锁出现的4个条件

  • 互斥 一个资源每次只能被一个线程使用

  • 请求与保持 一个线程因请求资源而阻塞时,对方已获得资源保持不释放

  • 不剥夺 线程已获得资源,在未使用之前不能强行剥夺

  • 循环等待 若干线程之间形成一个头尾相接的循环等待资源关闭

避免死锁的几种常见方式

  • 避免一个线程同时获取多个锁 当线程1获取A资源在获取B资源,线程2获取B资源在获取A资源,导致交叉等待,造成死锁

  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源

  • 尝试使用超时锁来代替内部锁机制

  • 对于数据库锁,加锁和解锁在一个数据库连接里,否则会出现解锁失败

查看

查看当前运行中的java进程

可以查看到正在死锁的进程46300

jps -l

查看指定进程的堆栈信息

可以查看到Found one Java-level deadlock,说明线程中出现了死锁

jstack 46300

Java stack information for the threads listed above:

...

Found 1 deadlock.

上下文切换

CPU工作分为2种状态:管态(内核态)和目态(用户态)

内核态和用户态

硬件中即运行了操作系统和普通用户程序,为了安全和稳定性,操作系统的程序不能随意访问,即需要执行操作系统的程序就必须转换到内核态才能执行.只有内核态才可以使用计算机的所有硬件资源

而用户态不能直接执行硬件资源,也不能改变CPU的工作状态,只能访问用户程序自己的存储空间

Ring级

操作系统通过Ring级来控制,Ring0拥有最高权限,Ring3拥有最低权限.操作系统运行在最核心层Ring0,用户程序运行在Ring3,有要求才向Ring0调用,这样可以有效保障操作系统的安全性

当一个进程因为系统调用陷入内代码中执行时处于Ring0(内核态)权限最高.执行内核代码就使用当前进程的内核栈.如执行文件操作、网络数据发送等操作必须有系统调用,这些系统调用会调用内核代码,进程会切换到Ring0,然后进入内核地址空间去执行.内核态的进程执行完后又会切换到Ring3,回到用户态.这样用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用.保护模式是指通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程地址空间中的数据