0%

Java - 锁

并发编程离不开锁,而Java有各种各样的锁,并且根据锁的特性不同,适用的场景也不同,本文会对锁的一些概念进行介绍。

这些概念不光是适用于Java中的锁,也适用于其他各种需要用到锁的场景。

本文只讲概念,不涉及到细节,相关的实现细节在后续具体的某个锁的文章中会讲解。

乐观锁/悲观锁

乐观锁和悲观锁,是指看待并发问题的角度不同,从而引出的不同上锁时机的两种概念。

  • 悲观锁

    悲观锁认为,自己在修改某一个数据的时候,一定也会有其他并发的线程来修改数据,因此会在操作数据之前,就对数据进行上锁。

    例如synchronized/lock等都属于悲观锁的范畴,在执行数据修改之前就上锁,保证同时只有一个线程在修改数据。

  • 乐观锁

    乐观锁认为,自己在访问、修改某一个数据的时候,不会有其他线程来修改数据,因此不会加锁,只是在最终修改数据的时候做一个验证,判断一下修改之前有没有别的线程更新了当前数据。

    一种常用的方式,是在数据库中,给每条数据增加一个version来标识当前行的版本,每次修改数据的时候,判断一下version是否是修改之前取出来的version,如果是,则修改数据,并递增version;如果不是,则报错或者采用其他业务逻辑。这种方式便属于乐观锁的范畴。

    update ... where version = ...的这种操作是使用无锁编程实现的,一般是采用CAS算法,下文会有讲解。

由于加锁是需要一定性能消耗的,因此悲观锁更适合写操作多的场景,乐观锁更适合读操作多的场景。

共享锁/独享锁

  • 独享锁

    独享锁同时只能被一个线程持有,数据被某个线程加了独享锁之后,其他线程便无法再对其加独享锁或者共享锁。

  • 共享锁

    共享锁同时可以被多个线程持有,数据被某个线程加了共享锁之后,其他线程依然可以对其加共享锁,但是无法对其加独享锁。

一般来说,共享锁都是读锁,获得这个锁的线程可以任意读取数据,但是不能写数据;独享锁是写锁,获取这个锁的线程可以任意读取、修改数据。

可重入锁

可重入锁又名递归锁,是指当一个线程获取了一个锁之后,在释放锁之前,如果嵌套的代码中需要再次获取这个锁(必须是同一个锁对象),则此时不会因为锁没有释放而阻塞。

例如一个类中有如下的两个函数:

1
2
3
4
5
6
7
8
public synchronized void func1() {
System.out.println("func 1");
func2();
}

public synchronized void func2() {
System.out.println("func 2");
}

由于synchronized是一个可重入锁,并且在这个例子中,锁住的都是同一个对象,因此调用函数func1之后,会连续打印两个函数中的log,并不会在调用func2的时候阻塞。

自旋锁/适应性自旋锁

自旋锁

当一个线程需要等待锁的时候,会挂起当前线程,在获得锁的时候,又需要恢复现场,这两步都需要操作系统切换CPU状态来实现,而切换状态本身需要耗费处理器的时间。如果同步块中的代码过于简单,那么执行代码所需的时间可能没有切换状态所需的时间长,那么在锁竞争不是很严重的情况下,这种切换状态就会得不偿失。

为了解决上述场景的问题,衍生出来自旋锁的概率。自旋锁是指当遇到锁冲突的时候,并不是切换CPU状态,挂起线程等待,而是让当前线程“自旋”,采用循环的方式不断去尝试获取锁。

自旋的好处是可以减少线程切换的消耗,但是由于一直在循环尝试获取锁,因此会占用处理器的时间。如果锁被占用的时间很短,那么自旋锁很快便可以获得锁;反之,则自旋锁会一直占用CPU,白白浪费处理器资源。

适应性自旋锁

适应性自旋锁是一种自适应的自旋锁,这种锁的自旋次数不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

在同一个锁对象上,如果自旋等待成功获得过锁,并且当前持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;如果自旋等待很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

分段锁

分段锁,是一种锁的设计,主要用于提高并发场景下的性能,最为大家熟知的是用于ConcurrentHashMap(JDK 1.7)中。

在分段锁的设计中,数据会被按照一定的规则分段(一般是对数据的hashcode,进行二次哈希计算),每个段使用一个锁,这样如果访问的数据不在一个分段中,则不会产生锁冲突,从而提高并发性能。

全局变量的统计

分段锁能够保证当操作的数据在不同段的时候,锁不会冲突,但是如果是访问整个数据的全局变量呢?比如统计数据的的size。

采用分段锁的数据结构中,一般会对每个段单独统计一个count,因此计算一个容器的大小的时,就需要累加所有的count。最安全的做法,是对每个段,都进行加锁,然后累加count,这样计算出来的数据是准确的,能够防止前脚累加了某个段的count之后,后脚数据就被修改了,但是因为要对所有数据上锁,所以性能不是最优的。

一种综合性能和数据准确性的方法是,先对所有段,在不加锁的情况下进行两次累加count计算,在累加的过程中同时对每个段进行标记,来记录累加过程中数据是否有变化,如果两次累加的结果一致,并且标记数据没有变化,则认为累加过程中没有受到数据变动的干扰,则返回累加数据;否则认为累加数据过程中受到了干扰,则再对所有段加锁之后进行累加。

公平锁/非公平锁

公平锁和非公平锁,是指锁释放的时候,其他线程竞争锁的策略。

  • 公平锁

    所有需要申请锁的线程,会根据先来后到的原则,进入到队列中排队等待,等排到队首位置时才能在锁释放之后获得锁。

  • 非公平锁

    线程需要申请锁时,会先尝试直接获取锁,若此时锁正好被释放,而且还没有交给等待队列中队首的线程时,则当前线程可以直接获取锁;若尝试直接获取锁失败,则再进入队列中按顺序等待。因此非公平锁有可能出现后申请锁的线程,反而先获得锁的情况。

采用公平锁的机制,可以保证每个等待锁的线程不会“饿死”,但是由于除了队列中第一个线程,其他线程都会被阻塞,因此增加了CPU唤醒线程的开销。

采用非公平锁的机制,线程由于一直被插队,所以有可能等很久才会获得锁。但是线程如果插队成功,则不用阻塞,因此能降低线程唤醒的开销,提高整体吞吐效率。

参考



-=全文完=-