java 并发-互斥同步(共享资源)

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问(原子操作),第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。

原子性

原⼦(atomic)本意是“不能被进⼀步分割的最⼩粒⼦”,⽽原⼦操作(atomic operation)意为“不可被中断的⼀个或⼀系列操作”。在多处理器上实现原⼦操作就变得有点复杂。让我们⼀起来聊⼀聊在Intel处理器和Java⾥是如何实现原⼦操作的。

(1)处理器如何实现原⼦操作

(1)使⽤总线锁保证原⼦性

(2)使⽤缓存锁保证原⼦性

针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他⼀些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它

(2)Java如何实现原⼦操作

在Java中可以通过锁和循环CAS的⽅式来实现原⼦操作。

(1)使⽤循环CAS实现原⼦操作

JVM中的CAS操作正是利⽤了处理器提供的CMPXCHG指令实现的。

⾃旋CAS实现的基本思路就是循环进⾏CAS操作直到成功为⽌,以下代码实现了⼀个基于CAS线程安全的计数器⽅法safeCount和⼀个⾮线程安全的计数器count。

AtomicInteger类的 compareAndSet 方法

private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
    final Counter cas = new Counter();
    List<Thread> ts = new ArrayList<Thread>(600);
    long start = System.currentTimeMillis();
    for (int j = 0; j < 100; j++) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    cas.count();
                    cas.safeCount();
                }    
            }
        });
        ts.add(t);
    }
    for (Thread t : ts) {
        t.start();
    }
    // 等待所有线程执⾏完成
    for (Thread t : ts) {
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println(cas.i);
    System.out.println(cas.atomicI.get());
    System.out.println(System.currentTimeMillis() - start);
}
/** * 使⽤CAS实现线程安全计数器 */
private void safeCount() {
    for (;;) {
        int i = atomicI.get();
        boolean suc = atomicI.compareAndSet(i, ++i);
        if (suc) {
            break;
        }
    }
}
/**
* ⾮线程安全计数器
*/
private void count() {
    i++;
}

从Java 1.5开始,JDK的并发包⾥提供了⼀些类来⽀持原⼦操作,如AtomicBoolean(⽤原⼦⽅式更新的boolean值)、AtomicInteger(⽤原⼦⽅式更新的int值)和AtomicLong(⽤原⼦⽅式更新的long值)。这些原⼦包装类还提供了有⽤的⼯具⽅法,⽐如以原⼦的⽅式将当前值⾃增1和⾃减1。

CAS实现原⼦操作的三⼤问题:

1)ABA问题 。因为CAS需要在操作值的时候,检查值有没有发⽣变化,如果没有发⽣变化则更新,但是如果⼀个值原来是A,变成了B,又变成了A,那么使⽤CAS进⾏检查时会发现它的值没有发⽣变化,但是实际上却变化了。ABA问题的解决思路就是使⽤版本号。在变量前⾯追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包⾥提供了⼀个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet⽅法的作⽤是⾸先检查当前引⽤是否等于预期引⽤,并且检查当前标志是否等于预期标志,如果全部相等,则以原⼦⽅式将该引⽤和该标志的值设置为给定的更新值。

2)循环时间长开销⼤ 。⾃旋CAS如果长时间不成功,会给CPU带来⾮常⼤的执⾏开销。如果JVM能⽀持处理器提供的pause指令,那么效率会有⼀定的提升

对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大(线程即使加不上锁也不沉睡,虽然不会引起上下文切换对资源的消耗,但线程会不停的尝试,空转浪费CPU时间片),从而浪费更多的 CPU 资源,效率低于 synchronized(线程加不上锁就进行系统调用使线程进入沉睡,等待别的线程释放锁之后被动通知他)。

3)只能保证⼀个共享变量的原⼦操作 。当对⼀个共享变量执⾏操作时,我们可以使⽤循环CAS的⽅式来保证原⼦操作,但是对多个共享变量操作时,循环CAS就⽆法保证操作的原⼦性,这个时候就可以⽤锁。还有⼀个取巧的办法,就是把多个共享变量合并成⼀个共享变量来操作。⽐如,有两个共享变量i=2,j=a,合并⼀下ij=2a,然后⽤CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引⽤对象之间的原⼦性,就可以把多个变量放在⼀个对象⾥来进⾏CAS操作。

(2)使⽤锁机制实现原⼦操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的⽅式都⽤了循环CAS,即当⼀个线程想进⼊同步块的时候使⽤循环CAS的⽅式来获取锁,当它退出同步块的时候使⽤循环CAS释放锁。

 

互斥同步

synchronized 和 ReentrantLock。

非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步

1. CAS

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

2. AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作

以下代码使用了 AtomicInteger 执行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 Unsafe 的 getAndAddInt() 。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt() 在一个循环中进行,发生冲突(在执行加一写会之前,有之前读到的预期值已经发生了改变)的做法是不断的进行重试

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

3. ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1. 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
100
100

2. 线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}
1

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。

3. 可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

程序在运行过程中可以被打断,并由开始处再次执行,并且在合理的范围内(多次重入,而不造成堆栈溢出等其他问题),程序可以在被打断处继续执行,且执行结果不受影响。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

所有的可重入函数都是线程安全的,但并非所有的线程安全函数都是可重入的.

例:可重入代码指可被多个函数或程序凋用的一段代码(通常是一个函数),而且它保证在被任何一个函数调用时都以同样的方式运行,如下,无论谁调用它结果都一样。

void test() {
		int i;
		i = 2;
		System.out.println(i);
		i++;
		System.out.println(i);
	}

但下面的就不一样了,对不同的调用,结果不一样。

        static int i =2;
	void test() {
		System.out.println(i);
		i++;
		System.out.println(i);
	}

 

 


一、synchronized

1. 同步一个代码块

public void func() {
    synchronized (this) {
        // ...
    }
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9

2.同步一个方法

public synchronized void func () { // ... }

它和同步代码块一样,作用于同一个对象

3. 同步一个类

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

public class SynchronizedExample {

    public void func2() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func2());
    executorService.execute(() -> e2.func2());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

 

4. 同步一个静态方法

public synchronized static void fun() {
    // ...
}

作用于整个类。

锁优化

从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不会是⼀个很重量级的锁。优化机制包括⾃适应锁、⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁。

锁的状态从低到⾼依次为⽆锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到⾼。

img

自旋锁:由于⼤部分时候,锁被占⽤的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程⽤户态和内核态的来回上下⽂切换严重影响性能。⾃旋的概念就是让线程执⾏⼀个忙循环,可以理解为就是啥也不⼲,防⽌从⽤户态转⼊内核态,⾃旋锁可以通过设置-XX:+UseSpining来开启,⾃旋的默认次数是10次,可以使⽤-XX:PreBlockSpin设置。

操作系统需要两种CPU状态——用户态与内核态

内核态(Kernel Mode):运行操作系统程序,操作硬件

用户态(User Mode):运行用户程序

指令划分

特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字PWS 设置时钟 允许/禁止终端 停机

非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)

特权级别

特权环:R0、R1、R2和R3

R0相当于内核态,R3相当于用户态;

不同级别能够运行不同的指令集合;

CPU状态之间的转换

用户态--->内核态:唯一途径是通过中断、异常、陷入机制(访管指令)

内核态--->用户态:设置程序状态字PSW

内核态与用户态的区别

  • 内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;

  • 当程序运行在0级特权级上时,就可以称之为运行在内核态。

  • 运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态(比如操作硬件)。

  • 这两种状态的主要差别是

  • 处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理器是可被抢占的

  • 处于内核态执行时,则能访问所有的内存空间和对象,且所占有的处理器是不允许被抢占的。

通常来说,以下三种情况会导致用户态到内核态的切换

  • 系统调用

这是用户态进程主动要求切换到内核态的一种方式用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作比如前例中fork()实际上就是执行了一个创建新进程的系统调用。

系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

用户程序通常调用库函数,由库函数再调用系统调用,因此有的库函数会使用户程序进入内核态(只要库函数中某处调用了系统调用),有的则不会。

  • 异常

当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

  • 外围设备的中断

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,

如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。


内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。

用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

为什么要有用户态和内核态?

由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级 -- 用户态和内核态。

所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据, 或者从键盘获取输入等. 而唯一可以做这些事情的就是操作系统, 所以此时程序就需要先操作系统请求以程序的名义来执行这些操作.

这时需要一个这样的机制: 用户态程序切换到内核态, 但是不能控制在内核态中执行的指令

这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)

他们的工作流程如下:

  1. 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈(stack frame), 以此表明需要操作系统提供的服务.

  2. 用户态程序执行陷阱指令

  3. CPU切换到内核态, 并跳到位于内存指定位置的指令, 这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问

  4. 这些指令称之为陷阱(trap)或者系统调用处理器(system call handler). 他们会读取程序放入内存的数据参数, 并执行程序请求的服务

  5. 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果

从用户态到内核态切换可以通过三种方式:

  1. 系统调用。系统调用本身就是中断,但是软件中断,跟硬中断不同。
  2. 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
  3. 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。

上下文切换

一个线程在它的整个生命周期中,只可能有一次处于NEW状态和TERMINATED状态。而一个线程的状态从RUNNABLE状态转换为BLOCKED、WAITING和TIMED_WAITING这几个状态中的任何一个状态都意味着上下文切换(Context Switch)。

上下文切换就好比我们接听手机电话的场景。比如,我们正在接听一个电话并与对方讨论某件事情的时候,这时候突然有另外一个来电。通常这个时候我们会跟对方说:“我先接个电话,你别挂断”,并在脑海中记录下和对方的讨论进行到什么程度了。然后,接听新的来电并且告诉对方稍后会回拨并将该来电挂断。接着,我们又会继续先前的讨论。如果在接听新来电之前,自己脑海中没有记录下当时的讨论进行到什么程度了,那么我们有可能会问对方“刚才我们讲到哪里了”这样的问题。

多线程环境中,当一个线程的状态由RUNNABLE转换为非RUNNABLE(BLOCKED、WAITING或者TIMED_WAITING)时相应线程的上下文信息需要被保存,以便于相应线程稍后再次进入RUNNABLE状态时能够在之前的执行进度的基础上继续执行,而一个线程的状态由非RUNNABLE状态转换进入RUNNABLE状态时可能涉及恢复之前保存的线程的上下文信息并在此基础上继续执行。这种对线程的上下文信息进行保存和恢复的过程就被称为上下文切换


锁机制与系统调用

锁的开销

现在锁的机制一般使用 futex(fast Userspace mutexes),内核态和用户态的混合机制。还没有futex的时候,内核是如何维护同步与互斥的呢?系统内核维护一个对象,这个对象对所有进程可见,这个对象是用来管理互斥锁并且通知阻塞的进程。如果进程A要进入临界区,先去内核查看这个对象,有没有别的进程在占用这个临界区,出临界区的时候,也去内核查看这个对象,有没有别的进程在等待进入临界区,然后根据一定的策略唤醒等待的进程。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问题,Futex就应运而生。

Futex是一种用户态和内核态混合的同步机制。首先,同步的进程间通过mmap共享一段内存,futex变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不用再执行系统调用了。当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex就是通过在用户态的检查,(motivation)如果了解到没有竞争就不用陷入内核了,大大提高了low-contention时候的效率。

mutex 是在 futex 的基础上用的内存共享变量来实现的,如果共享变量建立在进程内,它就是一个线程锁,如果它建立在进程间共享内存上,那么它是一个进程锁。pthread_mutex_t 中的 _lock 字段用于标记占用情况,先使用CAS判断_lock是否占用,若未占用,直接返回。否则,通过__lll_lock_wait_private 调用SYS_futex 系统调用迫使线程进入沉睡。 CAS是用户态的 CPU 指令,若无竞争,简单修改锁状态即返回,非常高效,只有发现竞争,才通过系统调用陷入内核态。所以,FUTEX是一种用户态和内核态混合的同步机制,它保证了低竞争情况下的锁获取效率。

自适应锁:自适应锁就是自适应的自旋锁,自旋锁的时间不是固定时间,而是由前⼀次在同⼀个锁上的⾃旋时间和锁的持有者状态来决定。

锁消除:锁消除指的是JVM检测到⼀些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进⾏锁消除。

锁粗化:锁粗化指的是有很多操作都是对同⼀个对象进⾏加锁,就会把锁的同步范围扩展到整个操作序列之外。

偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录⾥存储偏向锁的线程ID,之后这个线程再次进⼊同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第⼀个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进⾏同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以⽤过设置-XX:+UseBiasedLocking开启偏向锁。

HotSpot [1] 的作者经过研究发现,⼤多数情况下,锁不仅不存在多线程竞争,⽽且总是由同⼀线程多次获得,为了让线程获得锁的代价更低⽽引⼊了偏向锁。当⼀个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录⾥存储锁偏向的线程ID,以后该线程在进⼊和退出同步块时不需要进⾏CAS操作来加锁和解锁,只需简单地测试⼀下对象头的Mark Word⾥是否存储着指向当前线程的偏向锁。如果测试成功,表⽰线程已经获得了锁。如果测试失败,则需要再测试⼀下Mark Word中偏向锁的标识是否设置成1(表⽰当前是偏向锁):如果没有设置,则使⽤CAS竞争锁;如果设置了,则尝试使⽤CAS将对象头的偏向锁指向当前线程。

偏向锁使⽤了⼀种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁在Java 6和Java 7⾥是默认启⽤的,但是它在应⽤程序启动⼏秒钟之后才激活,如有必要可以使⽤JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应⽤程序⾥所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进⼊轻量级锁状态。

轻量级锁:JVM的对象的对象头中包含有⼀些锁的标志位,代码进⼊同步块的时候,JVM将会使⽤CAS⽅式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试⾃旋来获得锁。

因为⾃旋会消耗CPU,为了避免⽆⽤的⾃旋(⽐如获得锁的线程被阻塞住了),⼀旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进⾏新⼀轮的夺锁之争。

锁升级的过程非常复杂,简单点说,偏向锁就是通过对象头的偏向线程ID来对⽐,甚⾄都不需要CAS了,⽽轻量级锁主要就是通过CAS修改对象头锁记录和⾃旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。

二、ReentrantLock重入锁

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
}

public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

1、重入

  • 可重入锁:一个线程调用一个加锁的方法后,还可以调用其他加同一把锁的方法.(场景)
  • 不可重入锁:一个线程获取到一把锁之后,该线程是无法调用其他加了该锁的方法.这种情况,容易造成死锁.比如:方法一添加了不可重入锁,方法二添加了不可重入锁,并且调用了方法一.这种情况下,调用方法二就会出现死锁的情况.那么.重入锁就可以避免这种情况.
  • 常用的可重入锁:
    Sychronized,java.util.concurrent.locks.ReentrantLock

重⼊锁ReentrantLock,顾名思义,就是⽀持重进⼊的锁,它表⽰该锁能够⽀持⼀个线程对资源的重复加锁。除此之外,该锁的还⽀持获取锁时的公平和⾮公平性选择。

回忆在同步器⼀节中的⽰例(Mutex),同时考虑如下场景:当⼀个线程调⽤Mutex的lock()⽅法获取锁之后,如果再次调⽤lock()⽅法,则该线程将会被⾃⼰所阻塞,原因是Mutex在实现tryAcquire(int acquires)⽅法时没有考虑占有锁的线程再次获取锁的场景,⽽在调⽤tryAcquire(int acquires)⽅法时返回了false,导致该线程被阻塞。简单地说,Mutex是⼀个不⽀持重进⼊的锁。⽽synchronized关键字隐式的⽀持重进⼊,⽐如⼀个synchronized修饰的递归⽅法,在⽅法执⾏时,执⾏线程在获取了锁之后仍能连续多次地获得该锁,⽽不像Mutex由于获取了锁,⽽在下⼀次获取锁时出现阻塞⾃⼰的情况。

ReentrantLock虽然没能像synchronized关键字⼀样⽀持隐式的重进⼊,但是在调⽤lock()⽅法时,已经获取到锁的线程,能够再次调⽤lock()⽅法获取锁⽽不被阻塞

(1)如何实现重入?

重进⼊是指任意线程在获取到锁之后能够再次获取该锁⽽不会被锁所阻塞,该特性的实现需要解决以下两个问题:

1)线程再次获取锁 。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。

        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //getState 为AbstractQueuedSynchronizer的方法,获取当前重入锁的状态
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

该⽅法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进⾏增加并返回true,表⽰获取同步状态成功。

2)锁的最终释放 。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进⾏计数⾃增,计数表⽰当前锁被重复获取的次数,⽽锁被释放时,计数⾃减,当计数等于0时表⽰锁已经成功释放。

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)⽅法必须返回false,⽽只有同步状态完全释放了,才能返回true。可以看到,该⽅法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表⽰释放成功。

2、公平

这⾥提到⼀个锁获取的公平性问题,如果在绝对时间上,先对锁进⾏获取的请求⼀定先被满⾜,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了⼀个构造函数,能够控制锁是否是公平的

事实上,公平的锁机制往往没有⾮公平的效率⾼,但是,并不是任何场景都是以TPS作为唯⼀的指标,公平锁能够减少“饥饿”发⽣的概率,等待越久的请求越是能够得到优先满⾜。下⾯将着重分析ReentrantLock是如何实现重进⼊和公平性获取锁的特性,并通过测试来验证公平性获取锁对性能的影响。

 

三、比较

1. 锁的实现

synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。

2. 性能

新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。

3. 等待可中断

当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。

ReentrantLock 可中断(在死锁场景下,可以调用lock 自身具有的一个中断方法,会抛出异常而线程终端,另外一个线程获取资源终止死锁),而 synchronized 不行。

4. 公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。

synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。

5. 锁绑定多个条件

一个 ReentrantLock 可以同时绑定多个 Condition 对象。

四、Condition

任意⼀个Java对象,都拥有⼀组监视器⽅法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()⽅法,这些⽅法与synchronized同步关键字配合,可以实现等待/通知模式。Condition接⼜也提供了类似Object的监视器⽅法,与Lock配合可以实现等待/通知模式,但是这两者在使⽤⽅式以及功能特性上还是有差别的。

Condition的使⽤⽅式⽐较简单,需要注意在调⽤⽅法前获取锁,使用方式如下所示:

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
    lock.lock();
    try {
        condition.await();
    } finally {
        lock.unlock();
    }
} 
public void conditionSignal() throws InterruptedException {
    lock.lock();
    try {
        condition.signal();
    } finally {
        lock.unlock();
    }    
}

如⽰例所⽰,⼀般都会将Condition对象作为成员变量。当调⽤await()⽅法后,当前线程会释放锁并在此等待,⽽其他线程调⽤Condition对象的signal()⽅法,通知当前线程后,当前线程才从await()⽅法返回,并且在返回前已经获取了锁。

获取⼀个Condition必须通过Lock的newCondition()⽅法。下⾯通过⼀个有界队列的⽰例来深⼊了解Condition的使⽤⽅式。有界队列是⼀种特殊的队列,当队列为空时,队列的获取操作将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插⼊操作将会阻塞插⼊线程,直到队列出现“空位”,如下所⽰。

public class BoundedQueue<T> {
    private Object[] items;
    // 添加的下标,删除的下标和数组当前数量
    private int addIndex, removeIndex, count;
    private Lock lock = new ReentrantLock();
    private Condition notEmpty = lock.newCondition();
    private Condition notFull = lock.newCondition();
    public BoundedQueue(int size) {
        items = new Object[size];
    }
    // 添加⼀个元素,如果数组满,则添加线程进⼊等待状态,直到有"空位"
    public void add(T t) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[addIndex] = t;
            if (++addIndex == items.length)
                addIndex = 0;
                ++count;
                notEmpty.signal();
            } finally {
                lock.unlock();
            }
    }
    // 由头部删除⼀个元素,如果数组空,则删除线程进⼊等待状态,直到有新添加元素
    @SuppressWarnings("unchecked")
    public T remove() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            Object x = items[removeIndex];
            if (++removeIndex == items.length)
                removeIndex = 0;
            --count;
            notFull.signal();
            return (T) x;
        } finally {
            lock.unlock();
        }
    }
}

上述⽰例中,BoundedQueue通过add(T t)⽅法添加⼀个元素,通过remove()⽅法移出⼀个元素。以添加⽅法为例。⾸先需要获得锁,⽬的是确保数组修改的可见性和排他性。当数组数量等于数组长度时,表⽰数组已满,则调⽤notFull.await(),当前线程随之释放锁并进⼊等待状态。如果数组数量不等于数组长度,表⽰数组未满,则添加元素到数组中,同时通知等待在notEmpty上的线程,数组中已经有新元素可以获取。在添加和删除⽅法中使⽤while循环⽽⾮if判断,⽬的是防⽌过早或意外的通知,只有条件符合才能够退出循环。回想之前提到的等待/通知的经典范式,⼆者是⾮常类似的。

五、减少上下⽂切换

减少上下⽂切换的⽅法有⽆锁并发编程、CAS算法、使⽤最少线程和使⽤协程。

·⽆锁并发编程。多线程竞争锁时,会引起上下⽂切换,所以多线程处理数据时,可以用⼀些办法来避免使用锁,如将数据的ID按照Hash算法取

模分段,不同的线程处理不同段的数据。

·CAS算法。Java的Atomic包使用CAS算法来更新数据,⽽不需要加锁(加锁本身就是一种 CAS 操作,先用CAS判断是否已经被锁,如果没有则直接返回,如果有则系统调用使线程沉睡)。

·使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成⼤量线程都处于等待状态。

·协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

 

六、死锁避免

·避免⼀个线程同时获取多个锁。

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

·尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。

·对于数据库锁,加锁和解锁必须在⼀个数据库连接里,否则会出现解锁失败的情况。

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值