Java线程与锁
本篇是 《深入理解Java虚拟机》的最后一章, 在此涉及到了线程安全, 但并不是如何从代码层次来实现线程安全, 而是虚拟机本身对线程安全做出了哪些努力, 在安全与性能之间又采取了哪些优化措施.
那么一步步来梳理这些概念.
三种线程概念——内核线程、轻量级进程、用户线程
参考
内核线程(Kernel-Level Thread, KLT)
一个进程由于其运行空间的不同, 从而有内核线程和用户进程的区分, 内核线程运行在内核空间, 之所以称之为线程是因为它没有虚拟地址空间, 只能访问内核的代码和数据.
而用户进程则运行在用户空间, 不能直接访问内核的数据但是可以通过中断, 系统调用等方式从用户态陷入内核态, 但是内核态只是进程的一种状态, 与内核线程有本质区别.
内核线程就是内核的分身, 一个分身可以处理一件特定事情.内核线程的使用是廉价的, 唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间.支持多线程的内核叫做多线程内核(Multi-Threads kernel ).
- 内核线程只运行在内核态,不受用户态上下文的拖累.
- 处理器竞争:可以在全系统范围内竞争处理器资源;
- 使用资源:唯一使用的资源是内核栈和上下文切换时保持寄存器的空间
- 调度: 调度的开销可能和进程自身差不多昂贵
- 资源的同步和数据共享比整个进程的数据同步和共享要低一些.
内核线程没有自己的地址空间,所以它们的”current->mm”都是空的, 且内核线程只能在内核空间操作,不能与用户空间交互.
至于内核空间, 用户空间, 内核态, 用户态, 后文再来描述.
轻量级进程
轻量级进程(LWP)是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联.内核线程只能由内核管理并像普通进程一样被调度.
而轻量级进程就是我们通常意义上所说的线程.
与普通进程区别:LWP只有一个最小的执行上下文和调度程序所需的统计信息.
- 处理器竞争:因与特定内核线程关联,因此可以在全系统范围内竞争处理器资源
- 使用资源:与父进程共享进程地址空间
- 调度:像普通进程一样调度
每一个进程有一个或多个LWPs,每个LWP由一个内核线程支持.在这种实现的操作系统中,LWP就是用户线程.
由于每个LWP都与一个特定的内核线程关联,因此每个LWP都是一个独立的线程调度单元.即使有一个LWP在系统调用中阻塞,也不会影响整个进程的执行.
轻量级进程具有局限性.
首先,大多数LWP的操作,如建立、析构以及同步,都需要进行系统调用.系统调用的代价相对较高:需要在user mode和kernel mode中切换.
其次,每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间).因此一个系统不能支持大量的LWP.
注:
1.LWP的术语是借自于SVR4/MP和Solaris 2.x.
2.有些系统将LWP称为虚拟处理器.
3.将之称为轻量级进程的原因可能是:在内核线程的支持下,LWP是独立的调度单元,就像普通的进程一样.所以LWP的最大特点还是每个LWP都有一个内核线程支持.
用户线程
狭义上的 用户线程是完全建立在用户空间的线程库,用户线程的创建、调度、同步和销毁全又库函数在用户空间完成, 系统不能够感知到线程存在的实现.因此这种线程是极其低消耗和高效的.可以支持更大规模的线程数量.
但其优点在于对于系统而言是透明的, 缺点也在于此, 对于操作系统而言, 只是将处理器的资源分配给进程, 进程是处理器资源的最小调度单位, 当在进程中, 一个用户线程如果阻塞在系统调用中,则整个进程都将会阻塞.而同样的, 因为核心信号(无论是同步的还是异步的)都是以进程为单位的,无法定位到用户线程线程,所以这种实现方式不能用于多处理器系统.
因此, 在现实中,纯用户级线程的实现,除算法研究目的以外,几乎已经消失了.
用户线程 + LWP
用户线程库还是完全建立在用户空间中,因此用户线程的操作还是很廉价,因此可以建立任意多需要的用户线程.
操作系统提供了LWP作为用户线程和内核线程之间的桥梁.LWP还是和前面提到的一样,具有内核线程支持,是内核的调度单元,并且用户线程的系统调用要通过LWP,因此进程中某个用户线程的阻塞不会影响整个进程的执行.
用户线程库将建立的用户线程关联到LWP上,LWP与用户线程的数量不一定一致.当内核调度到某个LWP上时,此时与该LWP关联的用户线程就被执行.
Java实现
对于Sun JDK来说, 它的Windows版与Linux版都是使用一对一的线程模型实现的, 一条Java线程就映射到一条轻量级进程之中, 因为Windows和Linux系统提供的线程模型就是一对一的.
在Solaris平台中, 由于操作系统的线程特性可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)及多对多(通过LWP/Thread Based Synchronization实现)的线程模型,因此在Solaris版的JDK中也对应提供了两个平台专有的虚拟机参数:-XX:+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型.
但在这里我有点实在不理解, 对于Linux系统而言, 轻量级进程也即通常意义上的线程数量是有限的, 也就意味着一台Linux服务器所能支持的最大线程数不过是 几百上千, 而就我所理解的, 在Java中, 对应每个Controller请求都会创建相应的线程, 不止如此, 在代码运行中, 程序员也可能手动创建线程执行任务, 如此多的线程究竟是怎样被支撑起来的? 而相应的不说百万, 几万的并发量又是如何解决的?
即使是所谓的在多个线程之间进行切换, 但单台计算机本身能够创建的线程数是有限的, 特别是在Linux系统下, 一个线程就是一个轻量级进程, 只不过是恰好与其他 进程 共享 资源而已.
我所经手的系统, 还从未遇到过大并发的情况, 所以概念都只在猜测中.
但就目前了解到的而言:
Tomcat 默认配置的最大请求数是 150, 也就是说同时支持 150 个并发. 虽然可以自由调整, 其实也意味着同一台服务器所支持的并发数量必然不能太多, 更高的应该是要用分布式来解决这个问题.
创建的每一个线程都是要消耗CPU资源的, 种种因素就决定了服务器的最大承受并发数量.
内核空间 内核态 用户空间 用户态
关于这一段可以跳过, 因为这种种概念与之前所提到的线程关联并不是很紧密, 更多的是一种对其中概念的补充说明, 而内核态与用户态之间的切换, 与线程之间的切换分属于不同层面的东西.
参考
是谁来划分内存空间的呢?在电脑开机之前, 内存就是一块原始的物理内存.什么也没有.开机加电, 系统启动后, 就对物理内存进行了划分.当然, 这是系统的规定, 物理内存条上并没有划分好的地址和空间范围.这些划分都是操作系统在逻辑上的划分.不同版本的操作系统划分的结果都是不一样的.
内核空间中存放的是内核代码和数据, 而进程的用户空间中存放的是用户程序的代码和数据.不管是内核空间还是用户空间, 它们都处于虚拟空间中.
划分不同区域的目的, 为了让程序之间互不干扰, 即使电脑的浏览器崩溃, 也不会导致电脑蓝屏, 处于用户态的程序只能访问用户空间, 而处于内核态的程序可以访问用户空间和内核空间.那么用户态和内核态有什么区别呢?
当一个任务(进程)执行系统调用而陷入内核代码中执行时, 我们就称进程处于内核运行态(或简称为内核态).此时处理器处于特权级最高的(0级)内核代码中执行.当进程处于内核态时, 执行的内核代码会使用当前进程的内核栈.每个进程都有自己的内核栈.当进程在执行用户自己的代码时, 则称其处于用户运行态(用户态).即此时处理器在特权级最低的(3级)用户代码中运行.
而由用户态切换到内核态的方式有三种:
a. 系统调用
这是用户态进程主动要求切换到内核态的一种方式, 用户态进程通过系统调用申请使 用操作系统提供的服务程序完成工作, 比如前例中fork()实际上就是执行了一个创建新进程的系统调用.而系统调用的机制其核心还是使用了操作系统为用户 特别开放的一个中断来实现, 例如Linux的int 80h中断.
系统调用实际上是应用程序在用户空间激起了一次软中断, 在软中断之前要按照规范, 将各个需要传递的参数填入到相应的寄存器中.软中断会激起内核的异常处理, 此时就会强制陷入内核态(此时cpu运行权限提升), 软中断的异常处理函数会根据应用软件的请求来决定api调用是否合法, 如果合法选择需要执行的函数, 执行完毕后软中断会填入返回值, 安全地降低cpu权限, 将控制权交还给用户空间.所以内核提供的api调用, 你完全可以认为就是一个软件包, 只不过这些软件包你不能控制, 只能请求内核帮你执行.
b. 异常
当CPU在执行运行在用户态下的程序时, 发生了某些事先不可知的异常, 这时会触发由当前运行进程切换到处理此异常的内核相关程序中, 也就转到了内核态, 比如缺页异常.
c. 外围设备的中断
当外围设备完成用户请求的操作后, 会向CPU发出相应的中断信号, 这时CPU会 暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序, 如果先前执行的指令是用户态下的程序, 那么这个转换的过程自然也就发生了由用户态到 内核态的切换.比如硬盘读写操作完成, 系统会切换到硬盘读写的中断处理程序中执行后续操作等.
Java线程调度
参考
线程调度是指系统为线程分配处理器使用权的过程, 主要调度方式分两种, 分别是协同式线程调度和抢占式线程调度.
协同式线程调度, 线程执行时间由线程本身来控制, 线程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上.最大好处是实现简单, 且切换操作对线程自己是可知的, 没啥线程同步问题.坏处是线程执行时间不可控制, 如果一个线程有问题, 可能一直阻塞在那里.
抢占式调度, 每个线程将由系统来分配执行时间, 线程的切换不由线程本身来决定(Java中, Thread.yield()可以让出执行时间, 但无法获取执行时间).线程执行时间系统可控, 也不会有一个线程导致整个进程阻塞.
Java线程调度就是抢占式调度.
如果有兴趣可以了解下: 进程切换 这个概念
Java线程状态:
- NEW 状态是指线程刚创建,尚未启动,不会出现在Dump中.
- RUNNABLE 状态是线程正在正常运行中, 当然可能会有某种耗时计算/IO等待的操作/CPU时间片切换等, 这个状态下发生的等待一般是其他系统资源, 而不是锁, Sleep等, 主要不同是runable里面有2个状态, 可以理解为就是JVM调用系统线程的状态.
- BLOCKED 受阻塞并等待监视器锁.这个状态下, 是在多个线程有同步操作的场景, 比如正在等待另一个线程的synchronized 块的执行释放, 或者可重入的 synchronized块里别人调用wait() 方法, 也就是这里是线程在等待进入临界区
- WAITING 无限期等待另一个线程执行特定操作.这个状态下是指线程拥有了某个锁之后, 调用了他的wait方法, 等待其他线程/锁拥有者调用 notify / notifyAll 一遍该线程可以继续下一步操作, 这里要区分 BLOCKED 和 WATING 的区别, 一个是在临界点外面等待进入, 一个是在临界点里面wait等待别人notify, 线程调用了join方法 join了另外的线程的时候, 也会进入WAITING状态, 等待被他join的线程执行结束
- TIMED_WAITING 有时限的等待另一个线程的特定操作.这个状态就是有限的(时间限制)的WAITING, 一般出现在调用wait(long), join(long)等情况下, 另外一个线程sleep后, 也会进入TIMED_WAITING状态
- TERMINATED 这个状态下表示 该线程的run方法已经执行完毕了, 基本上就等于死亡了(当时如果线程被持久持有, 可能不会被回收)
锁
线程安全
线程安全
多个线程访问同一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或者在调用方进行任何其他操作, 调用这个对象的行为都可以获得正确的结果, 那么这个对象就是线程安全的.
而就目前的感觉,只要当需要考虑到线程安全的时候, 才需要提到锁这个概念.
而讨论线程安全的前提, 是在多个线程之间共享数据. 如果没有数据共享, 或根本没有多线程, 那么讨论线程安全也是没有意义的.
那么除了在Java内存模型中提到的Happens-before以外, 比较有总结性质的, Java中线程安全的描述如下:
- 不可变
在Java语言中(1.5内存模型修正以后), 不可变对象一定是线程安全的, 无论是对象的方法实现还是对象的调用者,都不需要再采取任何线程安全的保障措施, 只要一个不可变对象被正确的构建出来, 那么其外部状态就永远不会改变, 则必然是线程安全的.
而对于基本类型而言, 用final关键字进行描述的属性, 必然是不可变的, 这点在Java的内存模型中就有所定义, Java虚拟实现也对其做了相应保障.
而如果是个对象呢?需要保证的就是对象的行为不会对其状态产生任何影响.这个概念是什么意思呢?
不妨参考:
不可变对象指的是, 对象内部没有提供任何可供修改对象数据的方法, 如果需要修改共享变量的任何数据, 都需要先构建整个共享对象, 然后对共享对象进行整体的替换, 通过这种方式来达到对共享对象数据一致性的保证.
在设计时,需要注意以下几点:
- 不可变对象的属性必须使用final修饰, 以防止属性被意外修改, 并且final还可以保证JVM在该对象构造完成时该属性已经初始化成功(JVM在构造完对象时可能只是为其分配了引用空间, 而各个属性值可能还未初始化完成, (仅仅用private 修饰是不够的, 因为private无法保证在jvm初始化时的线程安全.)
- 属性的设值必须在构造方法中统一构造完成, 其余的方法只是提供的查询各个属性相关的方法;
- 对于可变状态的引用类型属性, 如集合, 在获取该类型的属性时, 必须返回该属性的一个深度复制结果, 以防止不可变对象的该属性值被客户端修改;
- 不可变对象的类必须使用final修饰, 以防止子类对其本身或其方法进行修改;
- 绝对线程安全
绝对线程安全就是符合本节开头定义的线程安全, 但是这代价往往是很大的, 甚至是不切实际的, 即使在JavaApi中标明了为线程安全的类, 在使用时同样需要注意, 因为它也不是绝对意义上的线程安全.
对于java.util.Vector而言, 几乎所有的方法都用 synchronized进行修饰, 但这依然不能保证线程安全.
至于代码就不贴在这里了, 如果想看的话, 在 深入理解java虚拟机第二版, 第十三章就有. 究其根本原因, 不过是因为检测与更改, 调用, 这些方法虽然本身具有原子性,但是联合调用 是不具有原子性的.
所以在javaApi中的线程安全,大都是相对安全的.
- 相对线程安全
相对线程安全就是通常意义上的线程安全, 它需要保证对这个对象的单独操作是线程安全的, 我们在调用时无需考虑线程安全问题, 但是在组合调用时, 依然需要进行保障, 在java中, Vector, HashTable, Collections.synchronizedCollection()等其他都是这种相对安全的.
- 线程兼容
指的是对象本身不是安全的, 但是可以通过在调用端正确的使用同步手段, 保证线程安全, 而平时所说的线程不安全, 即指这一类情况. 这种操作我们常常会用到, 即进行加锁操作.
- 线程对立
指的是无论采取怎样的措施在多线程情况下都无法同时调用的代码.
线程安全的实现
参考
- 互斥同步
互斥同步是一种常见的并发正确性保证手段, 主要是保证在多线程并发情况下, 在同一时刻, 只能够被一个线程(或一些, 使用信号量的情况下)使用.
互斥是实现同步的主要方式, 而实现的互斥的主要方式有:
- 二元信号量
二元信号量(Binary Semaphore)是一种最简单的锁, 它有两种状态:占用和非占用.它适合只能被唯一一个线程独占访问的资源.当二元信号量处于非占用状态时, 第一个试图获取该二元信号量锁的线程会获得该锁, 并将二元信号量锁置为占用状态, 之后其它试图获取该二元信号量的线程会进入等待状态, 直到该锁被释放.
- 信号量
多元信号量允许多个线程访问同一个资源, 多元信号量简称信号量(Semaphore), 对于允许多个线程并发访问的资源, 这是一个很好的选择.一个初始值为N的信号量允许N个线程并发访问.线程访问资源时首先获取信号量锁, 进行如下操作:
- 将信号量的值减1;
- 如果信号量的值小于0, 则进入等待状态, 否则继续执行;
访问资源结束之后, 线程释放信号量锁, 进行如下操作:
- 将信号量的值加1;
- 如果信号量的值小于1(等于0), 唤醒一个等待中的线程;
- 互斥量
互斥量(Mutex)和二元信号量类似,资源仅允许一个线程访问.与二元信号量不同的是,信号量在整个系统中可以被任意线程获取和释放,也就是说,同一个信号量可以由一个线程获取而由另一线程释放.而互斥量则要求哪个线程获取了该互斥量锁就由哪个线程释放,其它线程越俎代庖释放互斥量是无效的.
- 临界区
临界区(Critical Section)是一种比互斥量更加严格的同步手段.互斥量和信号量在系统的任何进程都是可见的,也就是说一个进程创建了一个互斥量或信号量,另一进程试图获取该锁是合法的.而临界区的作用范围仅限于本进程,其它的进程无法获取该锁.除此之处,临界区与互斥量的性质相同.
而在java中, 最基本的互斥同步手段就是通过 synchronized, 这个关键字在经过编译之后, 会生成 monitorenter 和 monitorexit 指令, 分别对应进入同步块 和退出 同步块, 这两个指令都需要一个 reference类型的参数来指明需要解锁, 加锁的对象, 这一点则和对象头有关, 稍后会提到.
至于synchronized选取的对象, 如果指定了对象, 无需多言, 而如果没有指定, 那么就根据锁定的 方法究竟是实例方法还是类方法, 去取对应的实例对象 又或者是 Class对象.
synchronized采取的方式就是互斥量, 不过是可重入的, 当需要获取锁的时候, 会去检测当前线程是否已经拥有了相应对象的锁, 如果已经拥有, 则计数器+1, 当当前线程释放锁的时候, 计数器减一. 当计数器为0表示当前对象没有被任何线程占用, 锁处于空闲状态. 如果没有, 则需要将线程阻塞, 等到锁被释放的时候再对线程进行唤醒.
然而, 在Java中, 线程的实现是与操作系统相挂钩的, 因此线程的阻塞, 唤醒都需要系统级别的支持, 需要将当前线程从用户态转移到核心态. 而如果线程阻塞时间很短, 又需要将线程唤醒, 这种切换状态的耗时甚至可能已经超过了执行代码本身的耗时, 是一种非常消耗资源, 时间的行为.
因此在Java1.6以后, 以经对synchronized做出了相当程度的优化. 而锁的另一种代码实现, ReentrantLock, 在1.6以后的版本上, 性能上的问题, 已经不是决定选择这两种锁中哪一种的根本原因.
ReentrantLock可以实现这样几种特别的需求:
- 等待可中断, 也就是设定等待时间, 超过之后,线程不再等待, 执行别的.
- 可实现公平锁, 在构造器中加入相应参数即可指定是否为公平锁, 默认是非公平锁, 公平锁是指多个线程等待同一把锁的时候, 根据申请的先后顺序来依次获取锁.
- 锁绑定多条件 当线程wait()之后, 可以被其他线程唤醒, 但是唤醒具有随机性, 即在等待当前锁中的任何一个线程, 而唤醒条件 就是 在等待当前锁的线程,可以通过不断嵌套, 将唤醒的线程最终锁定到某一个, 但这无疑是很不方便的, 因此想要指定更多唤醒条件时, 就需要通过 ReentrantLock newCondition();
至于newCondition()的使用:
参考
所以在非上述几种情况下, 还是使用 synchronized 比较合适, 这也是jvm优化的重点关注.
- 二元信号量
- 非阻塞同步
线程阻塞唤醒带来的主要问题就是性能问题, 这种同步属于互斥同步, 互斥同步是一种悲观同步, 即, 认为只要不进行同步操作就一定会带来安全问题, 无论是否真的需要同步, 有共享资源, 都会进行加锁操作 以及 用户态核心态转换, 维护锁计数器, 以及是否有线程需要被唤醒的检测操作.
而随着硬件指令集发展, 出现了另一种选择, 基于冲突检测的乐观并发策略, 通俗来说, 就是先操作, 如果成功了, 继续, 如果不成功则加锁.
而问题来了, 检测操作是两个动作, 线程不安全的核心问题也大都源于此, 那这种乐观锁又是如何保证的? 这就 硬件指令集的支持, 大多人可能已经有所耳闻, 即 CAS;
比较, 如果符合条件就更新, 如果不满足, 则返回原值.比较并交换 是一个原子性操作, 不再是被拆分成两步执行, 这也是乐观锁的核心所在, 既然已经知道了核心本质. 那么如何使用就不再是一个问题.网上资料不少, 我就不再费力.
而CAS操作由 sun.misc.Unsafe类里面的 compareAndSwapInt() 和 compareAndSwapLong() 提供, 虚拟机对这些方法做了特殊处理, 编译出来就是一条CAS指令. 而我们要是用的则是:
java.util.concurrent.atomic包下面的各种类即可. 如果使用基础类型的话, 考虑到线程安全, 使用这些类已经可以保证线程安全了.
而至于ABA问题, 一般情况下并不会产生实质性的影响, 如果想要避免还是使用互斥同步来进行实现即可.
- 无同步方案
如同一开始所提到的, 当线程之间不存在共享变量, 不存在数据竞争, 自然不需要同步操作.
而这里要提到的就是另一个类 ThreadLocal, 顾名思义, 本地线程, 对于每一个线程都会创建对应的副本, 具体用法并不想在这里多做说明. 很容易就找到很多例子.
它的核心实现大概是将当前线程 与 一张哈希表相关联, 即可.
那么该用在什么地方呢? 如果有一个变量, 我们需要进行共享, 却仅限于当前线程内进行共享操作, 就需要用到ThreadLocal, 对于一般的共享变量, 往往会导致线程安全问题, 又没有办法将实例控制在单一线程内产生或销毁, ThreadLocal就提供了这样一个功能. 就像session, 需要在一个会话中保存对应的数据, 却又不希望被其他线程感知, 共享.
在使用上, 需要注意内存泄露的问题, 特别是使用线程池的时候, 具体原因与ThreadLocal的实现有关.
可以参考: ThreadLocal可能引起的内存泄露
锁的分类
在细说锁之前, 还需要了解一个概念: monitor, 这就牵扯到了 Synchronized在底层究竟如何实现, 我们知道锁用的是对象, 那么究竟如何标识这个对象, 以标识对象已经被锁定?
参考
Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制.
在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者monitor.
每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表.每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用, 其结构如下:
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
- RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
对照图来说, 机制如下:
Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。
当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。
再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set队列中被唤醒的线程和entry-set队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。
参考
总的来说, 种种锁的分类, 实现, 目的是为了尽可能的细粒度化加锁, 也就是在绝对需要用到锁的地方加锁. 至于这个‘绝对需要‘ 本身的定义就成为了种种锁设计的动机所在. 锁的相关东西, 偏向于底层, 依赖于操作系统, 种种优化也大多是在jvm层面进行优化, 因此对其实现暂时并没有太高兴致.
- 从调度策略上来说, 有公平锁 非公平锁, 当然在Java中要使用非公平锁就需要ReentrantLock, 公平锁指的是根据申请锁的先后顺序分配锁给对应线程. 而非公平锁则指的是 将当锁被释放之后, 随机执行一个正在等待这个锁的线程.
- 而乐观锁, 悲观锁指的并非是某一特定的锁, 而是一种思想.
a. 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为存在竞争.因此对于同一个数据的并发操作,悲观锁采取加锁的形式.悲观的认为,不加锁的并发操作一定会出问题.
b. 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的.在更新数据的时候,会采用尝试更新,不断重新的方式更新数据.乐观的认为,不加锁的并发操作是没有事情的.
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升.
悲观锁在Java中的使用,就是利用各种锁.
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新.
- 自旋锁
这是JVM进行的一种优化, 对于需要同步的代码而言, 即使两个线程在争用同一把锁, 也不会使得另一个线程立刻进入阻塞状态, 不难想象, 当要执行的代码本身非常少时, 阻塞状态的切换, 唤醒等操作所需要消耗的时间, 性能影响都已经超过了执行代码本身时, 直接切入阻塞状态无疑是一件比较不友好的事情.
在这里就采取了一种自旋操作, 在每一次自旋中都需要判定是否锁已经被释放, 如果释放, 获取锁, 如果没有则继续自旋. 通过这种方式, 就避免了频繁的 不合时宜的 阻塞.
而自旋的次数, 用户可以通过 -XX:PreBlockSpin来进行修改, 默认是10次.
而自旋在1.6以后成为了自适应的, 如果在前一次获取锁的时候很快速, 并且成功了, 那么本次会多等一会, 如果很少获得成功, 那么会跳过自旋操作, 直接进入阻塞状态.
- 轻量级锁
轻量级锁是相对于使用操作系统的互斥量实现的"重量级锁"而言的.而轻量级锁并不是用来代替重量级锁的, 本意是为了减少多线程进入互斥的几率,并不是要替代互斥.
要理解轻量级锁, 需要了解 Mark Word这个概念, 虚拟机的对象头包含两部分, 第一部分用于存储自身的运行时信息, 如 哈希码, GC分代年龄等, 这部分被称为 Mark Word, 它是实现轻量级锁和偏向锁的关键, Mark Word被设计成一个非固定的数据结构,以便在最小的空间内存储尽量多的信息.
在对象未被锁定时, 存储信息在32位虚拟机下,mark word32bit的信息, 其中25bit用来存储对象的哈希码,4bit存储GC分代年龄信息,两bit存储锁标志位, 剩下1bit固定位0. 而在其他的状态下, 如下表所示:
存储内容 标志位 状态 对象哈希码, 对象分代年龄 01 未锁定 指向锁记录的指针 00 轻量级锁定 指向重量级锁(monitor)的指针 10 膨胀(重量级锁) 空, 不需要记录信息 11 GC标记 偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向 在代码进入同步块时, 如果对象没有被锁定, 虚拟机首先在当前线程的栈帧中建立一个名为 Lock Record(锁记录)的空间, 用于存储对象目前的 Mark Word的拷贝.
然后尝试以CAS的方式将对象的 Mark Word更新为指向Lock Record的指针, 如果更新成功, 则表示线程已经拥有了对象的锁, 并将对象的 Mark Word的标志位设置为00, 标识当前是处于 轻量级锁定状态下.
如果更新失败, 则检查是否其 Mark Record是否是指向当前线程的栈帧, 如果是的话, 表示已经获取到锁, 那么就可以直接进入同步块执行, 否则说明当前锁已经被其他线程抢占了.
如果两个线程争用同一把锁, 不论这种争用是发生在 CAS更新失败, 还是在初始时锁已经被占用, 那么轻量级锁就不再有效, 需要膨胀为重量级锁, 不仅仅Mark Word的标志位要变成"10", 而Mark Word中存储的指针也要变成指向 重量级锁(互斥量, monitor)的指针, 后面等待锁的线程也要进入阻塞状态.
而释放的时候, 也是通过CAS操作来进行, 如果Mark Word 仍然指向线程的 Lock Record, 则将Mark Word 与 Lock Record中存储的 Mark Word替换, 如果直接替换成功, 则表示释放锁, 如果替换不成功, 则说明其已经膨胀为重量级锁, 有其他线程尝试获取当前锁, 则 仍然是通过 Mark Word中存储的 monitor指针, 找到 wait set, 根据策略唤醒相应的线程.
- 偏向锁
假设虚拟机已经启动了偏向锁(-XX:+UseBiasedLocking, 在1.6中默认启动), 那么当锁第一次被线程获取到时, 虚拟机会将对象的 标识位 置为 01, 同时将线程的 ID 记录在对象的 Mark Word中, 如果CAS操作成功, 持有偏向锁的线程以后在每次进入相关同步块时, 虚拟机都无需进行任何同步操作(Locking, unlocking, mark word update等)
而一旦有另一个线程尝试获取锁时, 偏向模式即宣告结束, 如果对象目前未被锁定, 则撤销恢复至未锁定状态, 如果对象已经锁定, 那么升级成为轻量级锁, 其操作就不再多说.
且无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁, 锁只能升级, 不能降级.
原文地址:https://www.cnblogs.com/zyzdisciple/p/10234932.html