Java中的锁

本篇比较硬核,请谨慎观看 !!! 小朋友一定要带上父母才能看哟

  Java中的锁用来保护共享变量,实现对共享变量的互斥访问,即同一时间只能有一个线程访问该共享变量。
  Java程序天生就是多线程的,在实际编程中,多个线程协同工作更是常事,在这种情况下,对多线程共享的变量的保护便成了一件很重要的事。
  本文先谈笔者对锁的理解,之后再简要介绍一下Java种的各种锁。

锁是什么

  在我看来,锁是一种实现对共享变量进行互斥访问的一种机制。共享变量指多个线程共同访问的变量,在计算机中的表现就是内存地址相同的变量,Java 中可以简单地通过类的静态变量、单例对象或同一对象的引用来实现变量共享。
  至于为什么要做互斥访问,答案很简单,为了保证程序逻辑的正确,或者说一致性。现代CPU上有多个core,每个core都可以执行单独的线程,所以一个Java程序里的多个线程完全可能同时被调度到不同core上同时执行,再加上操作系统对线程的切换是不可预知的,任何时刻,任何一条指令执行完后都可能发生线程切换,所以程序代码可能停在任何一个地方。以 i++ 为例,CPU保证将变量 i 读入CPU这一过程是原子的(一条指令),执行 i 自增这一过程也是原子的(即不可能在这一过程中发生线程的切换),但是它无法保证这两个操作在一起执行时也是原子的,也就是说,在把 i 加载进 CPU 后,OS有可能会调度走这个线程,剥夺它的执行权,然后另一个线程执行,从内存中读取 i,之后把 i 加 1,写回内存。操作系统再调度到上一个线程,继续执行加 1 操作时,i 的值已经发生了变化,但是这个线程并不知道这种变化,于是它就在原来的基础上执行了自增操作,这就会产生不一致状态,因为我们使用多线程时希望获得与单线程程序相同的执行结果,执行两次自增操作后,i 的值要比原来大2,而不是大 1。
  对于这种错误,计算机学科的通用解决方案是对 i 进行互斥访问,同一时间只让一个线程访问这个变量。具体的实现方案就是在访问共享变量前要首先拿到对这个变量的唯一访问权,后续想要获得访问权的线程阻塞在这里,在访问后再释放对它的唯一访问权,允许其他线程访问。
  获取变量的访问权的过程称为上锁(lock),可以理解为我给这个变量上了锁,现在就只有我能访问这个变量了,其他人(线程)都进不来,都得等待锁被打开。释放对变量的访问权的过程称为解锁(unlock),可以理解为我把锁打开,其他的进程可以来尝试拿到锁去上锁,自己访问这个变量了。
  本质上来讲,如果只做到互斥访问,还是不够的。由于现代计算机的架构设计,一个CPU上有多个core,每个core有自己的cache,core只会从cache中读取数据和指令,所以内存中的数据需要先加载进cache后才能被核心访问到,而且为了减少内存访问,现代CPU大多采取write-back机制,即只把对变量的修改写回到cache,并不会刷新到内存中,所以上例中对 i 的修改只会写到线程所运行在的core的cache里,内存中 i 的值并没有发生变化,其他线程在执行时,从内存中读取 i 时,读到的还是之前的 i 的值,这也是有问题的,这个问题称为可见性问题。之前的线程互斥访问一般称为原子性问题。针对可见性问题,JVM 应该是提供了保证,保证被锁保护的共享变量的修改可以及时地刷回到内存/主存。笔者认为,JVM 应该是在 unlock 时强制刷新了一波cache到主存,同时使该共享变量在其他core的cache里失效(详情可搜索缓存一致性协议),强迫其他线程在访问 i 时必须从主存中获取最新值,保证可见性,即一个线程对共享变量的修改其他线程可以立马看到。
  下面我们来说说Java中的锁。

Java中的锁

  谈Java中的锁之前,我们先来说一下锁的分类。根据分类标准不同,锁有很多种分类:可重入锁和不可重入锁,公平锁和非公平锁,共享锁和独占锁,悲观锁和乐观锁。我们先来说一下这些分类到底是怎么划分的,顺带聊一下各种锁的特点。
  可重入锁和不可重入锁是按照线程能否重复获取来划分的。可重入锁指一个线程可以多次获取,不会发生阻塞的锁,不可重入锁指线程无法多次获取,只能获取一次,再次尝试获取就会阻塞到这个锁上的锁。很明显,不可重入锁除了某些特殊场合外,基本没有使用价值。
  公平锁和非公平锁是按照线程尝试获取锁的顺序与实际获得锁的顺序是否相同来划分的。如果线程按照先来后到的原则获得锁,不会发生锁的争抢,我们就说这个锁是公平的。如果线程获得锁的顺序与尝试获得锁的顺序不同,我们就说它是不公平的。很明显,非公平锁可能会发生饥饿问题,即可能发生线程永远争抢不到锁,永远无法继续执行的情况。
  共享锁和独占锁则是按照锁能不能被不同线程同时获取来划分的,如果一个锁可以被多个线程同时获取,那它就是共享锁,如果一个锁只能被一个线程获取,其他想要获取的线程都会被阻塞,那它就是独占的。有人会说,锁就是为了保护共享变量,实现互斥访问,为什么还会有共享锁呢?这其实很简单,如果你对共享变量进行访问时,并不修改它,而只是读取它,那么完全没有必要进行互斥,多个线程同时读取完全没有问题,如果此时还要强行互斥,那么就会降低程序的并发度,降低性能。
  悲观锁和乐观锁则是按照访问共享变量前需不需要加锁来划分的。没错,访问共享变量也可以不加锁,这也称为无锁编程。如果访问共享变量前必须加锁,那就称为悲观锁,如果访问共享变量前不用加锁,那就称为乐观锁。读到这里你肯定会想,不加锁怎么保护共享变量,这不是玩的吗?其实不是,不使用锁也可以保证线程安全。各种各样的指令集都会提供这样一种指令:比较并交换,也就是先比较这个变量的值是不是等于你给的值,如果不等,返回失败,如果相等,就用你给的另一个值替换掉原值,然后返回成功,这整个过程是原子的。于是就可以根据返回成功还是失败来判断这次更新操作是否成功。它的原理也很简单,如果有多个线程并发修改一个变量,那么我们只要保证在线程修改这个变量期间,其他线程并没有修改它,那我修改这个变量就是安全的,不会引发问题。那怎么保证安全呢?其实也很简单,那就是修改之前我先读一把,如果在我修改时,这个变量的值和读到的值相同,没有发生变化,那我就认为在我访问这个变量期间,并没有其他线程修改这个变量,我就可以进行修改,比较并判断是否相等和修改这个过程必须是原子的,线程不能被调度走,否则必然发生线程安全问题。这种比较并交换机制并不完美,它会引发ABA问题,也就是说,如果一个线程先读了一把,发现是A,然后OS把它调度走,让另一个线程获得了执行权,而这个线程把变量从A改成了B,然后又被OS调度走,OS又调度了第三个线程,这个线程又把变量从B改成了A,现在执行权给到第一个线程,它在执行比较并交换操作时发现变量仍是A,和之前读到的一样,认为该变量并没有发生修改,于是它进行自己的修改。然而,实际上这个变量已经被改变,它已经不是该线程第一次读到的A了,它是另一个A,通常情况下,这并不会有什么问题,然而,有些情况要求对它进行处理,这就会有问题。聊了这么多废话,我们继续说乐观锁和悲观锁,乐观锁是基于CAS操作及失败重试机制来做的,CAS操作就是我们前面说的比较并交换,compare and swap,整体就是一直执行 CAS 操作去尝试修改值,如果修改成功,那万事大吉,继续往下执行,否则,就读取变量,获得它的最新值,然后进行新一轮的CAS,直到成功。其实,Java里的悲观锁的实现都是基于乐观锁做的,或者说,都是基于CAS做的。
  谈了这么多,还是没说到锁,接下来就说说 Java 里的各种各样的锁。

ReentrantLock 可重入锁

  这应该是Java里最常用,最普通的锁了,它的行为很简单,调用 lock 方法尝试获取锁,如果获取到了,就直接返回,否则,把自己挂在这个锁上,阻塞自己,直到被唤醒;调用 unlock 释放锁,并唤醒阻塞在锁上的一个线程,然后返回。
  值得注意的是,默认情况下,创建的ReentrantLock是非公平锁,在构造器中给true会创建公平锁。ReentrantLock的公平与非公平指的是当有一个线程尝试获取锁时,它会不会与之前最先尝试获得这个锁失败而阻塞在这个锁上的线程发生争抢。对于公平锁,新尝试获取锁的线程会直接把自己挂在这个锁上去排队,玩先来后到。对于非公平锁,新尝试获取锁的线程则会与最先阻塞的线程抢一把,抢到了就往下执行,最先阻塞的线程继续阻塞,抢不到就把自己挂在阻塞线程的末尾,老老实实排队。
  至于代码,就没必要写了,这点代码是在没啥必要。

ReentrantReadWriteLock 可重入读写锁

  这个锁用于读比写多的情况,用来提高并发度。它其实有两个锁,读锁和写锁,即ReadLock、WriteLock,使用读写锁时必须先取出来读锁和写锁,然后按照是对共享变量读还是写分别加读锁和写锁。如果只是对共享变量进行读操作,则读之前加读锁,也就是调ReadLock的lock方法,只要对共享变量进行了写操作,那么在访问之前,就必须加写锁,也就是调WriteLock的lock方法。访问完共享变量后都要调各自的unlock方法。
  它的基本机制是读可以一块读,写必须一个一个写,读的时候遇到了写,那么后续的读都得等到写完成,写的时候遇到了读,读必须等到写完成。具体运行机制是这样的:如果获取的是读锁,那么后续的线程可以成功获取读锁,不会阻塞。如果获取的是写锁,那么后面的线程无论想获得读锁还是写锁,都必须阻塞。如果在获得读锁时,有线程想获取写锁,那它必须等到所有线程读完,即读锁被全部释放后才能去写。如果在获得写锁时,有线程想获得读锁,那么它们都得等到写操作完成,即写锁被释放。
  至于代码,等我想到一个案例再添上。

StampedLock 戳锁

  好吧,我承认,翻译成这个名字纯属恶搞。这个锁与前面的锁不同,它类似于数据库并发控制的时间戳并发控制机制,每次上读、写锁都会返回一个逻辑上的时间戳,根据这个时间戳来控制对数据的并发访问。
  它是读写锁的一种,是对ReentrantReadWriteLock的一种优化,应该会比ReentrantReadWriteLock来的快,因为它里面并没有上锁机制,没有CAS重试等待,完全是依据时间戳来进行的并发访问控制,不过在使用上会比较复杂。具体我还没看,等看完再更新相关部分。

Condition 条件变量

  详情见下回分解XXXX

synchronized 机制

  详情见下回分解XXXX

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值