三、共享模式之管程
1. 多线程共享的问题
临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。
临界区:每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。
简单来说当多个线程访问同一个资源,发生指令错乱时就会出现混乱。举个例子。同时对今天变量i,做自增(i++) 和自减(i--),理论上是0,但是实际上科恩那个是正也可能是负。 由于自增,自减在指令层面不是原子操作,所以会导致结果错乱。
2. Synchronized关键字
2.1加锁:synchronized,Lock(或者原子变量)。加锁采用了互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住(blocked)。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
锁和锁要保护的资源是要对应的。这个指的是两点:①我们要保护一个资源首先要创建一把锁;②锁要锁对资源,即锁A应该用来保护资源A,而不能用它来锁资源B。
2.2synchronized语法
(1)最初版
共享变量
对象创建
synchronized(对象) // 线程1, 线程2(blocked)
{ 临界区处理共享变量 }
synchronized(对象) // 线程2, 线程1(blocked)
{ 临界区处理共享 }
这里的对象(锁)和临界区的代码是毫无关系。
(2)改为面向对象
可以把对象,共享变量,和临界区代码封装为一个类。(面向对象编程)
public void method() {
synchronized (this) {
value++;
}
}
共享变量的声明和对象创建,变为一步。
另外可以直接通过对象和其附属方法处理共享变量。
3. 方法上的 synchronized(语法的优化)和线程八锁分析(考察 synchronized 锁住的是哪个对象)
4. 变量的线程安全
(1)原则
如果它们没有共享,则线程安全
如果它们被共享了
--如果只有读操作,则线程安全
--如果有读写操作,则这段代码是临界区,需要考虑线程安全
(2)基本类型和引用类型
基本类型 a=1; b=‘c’;c=true
引用类型
(3)静态变量一般会考虑线程安全问题,多个类对象都有权限更改。
(4)成员变量则会有线程安全问题
(5)局部变量中引用成员变量,也会导致线程安全问题。举个例子。
开启多个线程同时运行,每个线程中的方法有自增,子减,在指令层面会错乱。
String Integer StringBuffer Random Vector Hashtable java.util.concurrent 包下的类。这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。举个例子,线程1 调用put(); 于此同时,线程2调用put()。但是当出现实例的两个方法混用时,则不是线程安全的。
5. 习题分析
买票,针对一个共享变量。对临界区资源加锁。
转账,针对两个共享变量。通过对类加锁,来控制两个账户。
6. Monitor原理
6.1 Java的对象头的结构。
其中Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
当对象处于不同的锁状态时,它的Mark Word里的东西会被替换成不同东西。
1、对象未加锁的时候,lock标志位为01,包含哈希值、年龄分代和偏向锁标志位等,此时偏向锁标志位为0;
2、当对象被施加偏向锁时,哈希值和一部分无用内存会转化为锁主人的线程信息,以及加锁的时间戳epoch,此时lock标志位没变,偏向锁为1,也就是说,偏向锁和lock标志位共同决定是否偏向锁状态。
3、当发生锁竞争时,偏向锁会变为轻量级锁,这时需要先将偏向锁进行锁撤销,这一步骤也会消耗不少的性能,轻量级锁的Mark Word中,lock标志位为00,其余内容被替换为一个指针(轻量级锁地址),指向了栈里面的锁记录
4、如果竞争线程增多,锁继续膨胀,变为重量级锁,也是互斥锁,即synchronized,其lock标志位为10,Mark Word其余内容被替换为一个指向对象监视器Monitor的指针(重量级锁地址)。特殊的是,如果此对象已经被GC标记过,lock会变为11,不含其余内容。
6.2 锁的原理
每个对象都有一个Monitor对象相关联,Monitor对象中记录了持有锁的线程信息、等待队列等。Monitor对象包含以下三个字段:
owner 记录当前持有锁的线程
EntryList 是一个队列,记录所有阻塞等待锁的线程
WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程
当线程持有锁的时候,线程id等信息会拷贝进owner字段,其余线程会进入阻塞队列entrylist,当持有锁的线程执行wait方法,会立即释放锁进入waitset,当线程释放锁的时候,owner会被置空,公平锁条件下,entrylist中的线程会竞争锁,竞争成功的线程id会写入owner,其余线程继续在entrylist中等待。
6.3总之
1. 偏向锁。对象头的mark word中可以保留线程名,说明该对象对该线程所有。
2. 轻量级锁。CAS交换(线程和对象头)。线程的LockRecord 记录的是MarkWord的值,对象头的MarkWord 记录的是LockRecord地址。两个主体。
3. 重量级锁。线程绑定了对象后,对象记录了锁地址,锁里面存的是线程。三个主体。
7. Synchronized原理(重量锁,轻量锁和偏向锁)
简单说,一个新建的对象一开始是不加锁,或者偏向锁状态。随着情况不同逐渐升级。
7.1 轻量级锁
场景: 多个线程对一个对象加锁,但是不存在竞争关系(比如时间上错开,一个白天,一个晚上)
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录对象,内部可以存储锁定对象的mark word(不再一开始就使用Monitor)。让锁记录中的Object reference指向锁对象(Object),并尝试用cas去替换Object中的mark word,将此mark word放入lock record中保存。
(1)如果cas替换成功,则将Object的对象头替换为锁记录的地址和状态 00(轻量级锁状态),并由该线程给对象加锁。
(2)如果 cas 失败,有两种情况。第一种是如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数【锁重入】。第一种是如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程【锁膨胀】。
7.2 锁重入
场景: 同一个线程对同一个对象多次加锁
(1)当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
(2)当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。成功,则解锁成功;失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
7.3 锁膨胀
场景:一个线程已经对共享对象加了轻量级锁,与此同时另外一个线程也需要此对象。此时存在竞争关系,后面的线程就需要升级锁。
(1)当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
(2)Thread-1 加轻量级锁失败,进入锁膨胀流程。即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址。然后自己进入 Monitor 的 EntryList BLOCKED。
当然,Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
7.4 自旋优化
场景:重量级锁竞争时,还可以使用自选来优化,如果当前线程在自旋成功(使用锁的线程退出了同步块,释放了锁),这时就可以避免线程进入阻塞状态。自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
7.5 偏向锁
轻量级锁在没有竞争时,每次重入(该线程执行的方法中再次锁住该对象)操作仍需要cas替换操作,这样是会使性能降低的。所以引入了偏向锁对性能进行优化:在第一次cas时会将线程的ID写入对象的Mark Word中。此后发现这个线程ID就是自己的,就表示没有竞争,就不需要再次cas,以后只要不发生竞争,这个对象就归该线程所有。
(1)偏向状态
Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后两位为状态(00)
Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,后两位为状态(10)
(2)偏向操作
如果开启了偏向锁(默认开启),在创建对象时,对象的Mark Word后三位应该是101
但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态
如果没有开启偏向锁,对象的Mark Word后三位应该是001
(3)撤销偏向
调用对象的hashCode方法,调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
多个线程使用该对象,当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
调用了wait/notify方法(调用wait方法会导致锁膨胀而使用重量级锁)
(4)批量重偏向
如果对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向T1的对象仍有机会重新偏向T2重偏向会重置Thread ID
当撤销超过20次后(超过阈值),JVM会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程。
(5)批量撤销
当撤销偏向锁的阈值超过40以后,就会将整个类的对象都改为不可偏向的
7.6 锁消除
锁消除和锁粗化
8. Wait/Notify
8.1 原理
8.2 Wait与Sleep的区别
(1)不同点
Sleep是Thread类的静态方法,Wait是Object的方法,Object又是所有类的父类,所以所有类都有Wait方法。
Sleep在阻塞的时候不会释放锁,而Wait在阻塞的时候会释放锁。或者说Sleep只是释放CPU资源,并不会释放内存资源。而wait 则是和内存相关。
Sleep不需要与synchronized一起使用,而Wait需要与synchronized一起使用(对象被锁以后才能使用)
(2)相同点
阻塞状态都为TIMED_WAITING
8.3 优雅地使用wait/notify
当线程不满足某些条件,需要暂停运行时,可以使用wait。这样会将对象的锁释放,让其他线程能够继续运行。如果此时使用sleep,会导致所有线程都进入阻塞,导致所有线程都没法运行,直到当前线程sleep结束后,运行完毕,才能得到执行。
当有多个线程在运行时,对象调用了wait方法,此时这些线程都会进入WaitSet中等待。如果这时使用了notify方法,可能会造成虚假唤醒(唤醒的不是满足条件的等待线程),这时就需要使用notifyAll方法