深入理解Java高并发编程(3) - 共享模型 - 管程
1. 共享线程问题
对于多线程操作共享变量的读写场景,发生的指令交错,就会出现共享问题。
如果一段代码中存在多线程对共享变量的读写操作,这段代码就叫临界区
通过避免临界区的安全问题,有两种解决办法
- 阻塞式解决方案,synchronized,Lock
- 非阻塞式:原子变量(底层采用CAS)
2. 线程安全问题发生场景(访问修饰符对线程安全的影响)
- 对于共享的成员变量或者静态变量, 是可能会发生线程安全问题的
- 对于局部变量来说
- 如果局部变量没有暴露给外面,也是没问题的,因为局部变量引用是在私有栈中的,每次调用方法都会单独去创建一次局部变量
- 局部变量暴露的场景,当父类中的方法创建局部变量,并传给其他方法,子类如果重写这个其他方法(public)并在这个方法中新开线程,就能访问到父类中局部变量。
- 新创建子类,如果父类中的方法都是线程安全的,但确是public的,子类中的方法是无法控制的,重写父类方法就可能出现线程安全问题。 所以,访问修饰符是有意义的,private/final 修饰符能更好保证线程安全
3. Synchronized
synchronized也就是对象锁,是通过线程对某一对象上锁,之后再想拿到这个锁的线程就会被阻塞,等持锁线程执行完临界区代码后会释放锁,阻塞的线程再去竞争锁,保证了临界区代码的原子性,可见性,锁的释放和获取也存在happens-before关系,确认了一个线程释放锁中的操作,一定是在后续获取同一个锁的线程操作之前发生的。(有序性)
synchronized(对象) {
临界区
}
synchronized作用的对象可以是
- 实例
- this
- 语法加在成员方法上,相当于锁的是this
- class对象
- 对静态方法声明synchronized,相当于锁的是类的class对象
4. 线程安全类
- String
- 不可变类,substring,repalce方法中并没有加锁,而是通过重新创建对象,保证线程安全。
- 问题:为什么
String类是final修饰的- final能很好的保证线程安全,如果不设置未final,string类就可能被其他类继承,在其他类中就可能会发生线程安全问题。
- 包装类
- StringBuffer
- Ramdom
- Vector
- Hashtable
- juc包下的类
这里的线程安全指的是多个线程调用实例中的某个方法是安全的,是原子的,但是这些方法的组合是不能被保证安全的,原子的,需要人为处理线程安全问题。
5. Synchronize原理
5.1 对象头
对象头分为两个部分
-
markword
- 主要是运行环境的一些数据,包括hashcode,gc年龄,偏向锁状态,锁状态
-
类元数据指针
- 表面这个对象是哪个类的实例
5.2 Monitor(管程)
Monitor是操作系统层面的概念,每一个java对象可以关联一个Monitor对象。
Monitor组成
- WaitSet
- EntryList
- Owner
- 线程检查对象锁时,发现owner为null,则会把owner对象设置为当前线程地址。后续尝试持锁线程发现owner不为null,则会进入EntryList等待锁释放。
当某个线程持锁时,对象markword中会记录管程的地址。
5.3 轻量级锁
因为重量级锁的消耗太大,对于不存在竞争或者竞争较小的时候,轻量级锁能发挥更好的作用。
轻量级锁的语法仍然是synchronized,一开始不存在竞争时候可以用轻量级锁,当发生了锁竞争,这时候竞争的锁会不断执行临界区代码产生获取锁,当获取锁仍然失败,就会发生锁膨胀(轻量级锁升级为重量级锁)
当线程尝试获取轻量级锁时,首先会在栈帧中产生锁记录对象。
锁记录包括:
- 锁地址
- 对象指针
然后通过对象指针指向上锁对象,将锁地址通过CAS的方法和markword替换,也就是产生获取锁的过程。

如果CAS替换成功,对象中存储就是锁记录地址和状态,表示对象被线程上锁。

之后在有其他线程尝试获取锁,发现状态不为01,则获取锁失败,尝试自旋,重复执行获取锁行为,当多次自旋失败后就会发生锁膨胀。
如果是自己再次尝试获取锁,检查对象状态为01,但是是当前线程持有锁,就会再增加一条锁记录,作为重入的次数。支持锁重入

当持锁线程释放锁时,又会通过CAS把原先对象含有HashCode,gc age等markword置换回去。
如果成功,则说明解锁成功,失败则说明发生了锁膨胀,轻量级锁已经升级为重量级锁,进入重量级锁解锁过程。

5.4 自旋 & 锁膨胀
自旋指的是当线程尝试获取对象锁,发现另一线程持有对象的轻量级锁,线程不会立即进行锁膨胀,而是多次尝试获取轻量级锁,超过一定次数就进行锁膨胀,对于重量级锁,尝试去获取锁的线程也不会马上阻塞,而是自旋,发现拿不到锁,最后再阻塞。(在我看来像一种自我暗示机制,告诉当前线程使用管程的开销太大了,现在锁竞争没那么大,说不定下一秒就能持锁了)
当其他线程检查到对象已经被另一线程加了轻量级锁,且自旋失败,这时候就会发生锁膨胀,由轻量级锁升级为重量级锁。尝试持锁线程会为对象申请Monitor锁,让对象指向重量级锁地址,然后自己进入Monitor的EntryList阻塞。

5.5 偏向锁
轻量级锁还是有CAS操作,存在一定消耗,在此之上还可以采用偏向锁进行优化。
Java6中引入了偏向锁机制。只有第一次对象通过CAS把线程ID设置到Markword,之后该线程再次尝试获取锁发现线程id是自己的,就表示没有竞争,不需要重新CAS,之后只要不发生竞争,这个对象就归该线程所有。
偏向锁是默认延迟开启的,当创建一个对象,短暂延时之后,偏向状态会被设置1,也就是最后三位是101。

synchronized关键字对线程加锁,如果偏向锁没有被禁用的话,一开始都是获取偏向锁。当其他线程再来获取锁对象且其他线程不持锁(出现竞争),发现是偏向锁,再会升级为轻量级锁,当出现其他线程无法获取轻量级锁,且自旋失败(竞争扩大),最终升级为重量级锁。
偏向锁撤销场景:
- hashcode禁用偏向锁:当对象刚创建后,这时候为可偏向状态,如果立即调用了
hashcode方法则会撤销可偏向状态,进入正常状态001,这是因为hashcode31位,没有额外空间来存储hashcode了 - 当轻微竞争发生,偏向锁被其他线程获取,偏向锁会被撤销,升级为轻量级锁。
- 调用wait/notify也会撤销偏向锁,因为这种机制(waitSet)只存在于重量级锁
- 批量重偏向/撤销
- 偏向线程1的对象仍然可能偏向线程2,当线程2撤销偏向线程1的偏向锁次数超过 20 次,之后的偏向锁就会撤销,再偏向线程2
- 当撤销次数超过 40次, 则之后该类的所有对象创建都是不可偏向的状态,且只能加轻量级锁。
5.6 锁消除
当对某些不可能被共享的变量加锁(未暴露局部变量),由于JIT(即时编译器)对于反复执行的代码判定该对象不可能存在线程安全问题,就会把synchronized的代码优化掉。
6.wait/notify
当持锁线程发现自己某些条件不满足,能调用wait方法,进入waitSet,等待条件满足时被其他线程唤醒。
wait和block状态都是不占用时间片的。
当owner线程调用notify/notifyAll时候,wait的线程就会被唤醒,重新进入entrylist竞争锁。
- wait():不带参数的wait,会一直等待下去,抛出InterruptedException,底层是调用了wait(0)
- wait(long time):带参数wait,当超过某个时间,线程就会退出waitSet
- notify:唤醒waitset中一个线程
- notifyAll:唤醒waitset中所有线程
wait(long n) 对比 sleep(long n)
-
不同:
-
sleep 是Thread的静态方法,wait是object的方法
-
sleep不需要强制搭配synchronized使用,wait需要先持锁再wait,需要synchronized
-
sleep在睡眠时不会释放锁,而wait是会释放锁的。
-
-
相同:
- 线程状态是一样的,都是TimedWaiting
管程的waitSet只有一个,相比reentrantlock支持多个条件变量,就没那么灵活。
虚假唤醒是指某notifyAll导致未满足条件的wait线程被唤醒,管程中的wait可以搭配while使用,避免虚假唤醒问题。
7. park & unpark
park和unpark是LockSupport中的方法,作用和wait/notify类似,也是暂停线程和恢复暂停线程。
park/unpark方法接受一个线程作为参数,指定要阻塞的线程。
parkNanos(long nanos)/parkUntil(long millis)是带参数的park方法,超过某个时间对象会不再被阻塞继续运行,状态是TimedWating。
park/unpark 对比 wait/notify
- unpark可以在park之前调,如果先调用了unpark方法,即使后面线程调用park方法也不会停止。
- park/unpark不需要持重量级锁,wait/notify是需要持重量级锁的。
- park/unpark是以线程为单位来唤醒/阻塞线程,而notify只能随机唤醒一个等待线程/notifyAll唤醒所有线程,没那么精确。
原理:
每一个线程都会关联一个parker对象,cond就是等待队列。
当调用park时候,会检查counter是否为1,如果为1则不阻塞线程,并将counter消耗为0,如果为0 则进入等待队列。
每次调用unpark,都会讲counter设为1,多次调用unpark也只会把counter设为1.
park对象就像一个旅行者,cond是帐篷,决定线程是不是要休息,counter就像是干粮,一开始线程都是没带干粮的,counter = 0,当调用park方法去消耗干粮,发现没有干粮了,就跑进帐篷里休息(也就是进入等待队列cond),如果调用了unpark,就是补充了一份干粮,这时候线程会跑出来吃掉这份干粮继续运行,多次调用unpark也只会补充一份干粮。

8. 死锁/活锁/饥饿
-
死锁指的是:A,B两个对象,a线程持有a锁,b线程持有b锁,两个线程都想获得对方的锁,但是都不会释放锁这种现象。
- 当锁的颗粒度变小,锁变多,就更可能导致死锁的现象
- 解决办法:
- 1.可以让线程按同一顺序获取锁,但这样就可能导致饥饿的问题。
- 2.通过可打断的特性锁(reentrantlock),防止线程一直等待下去。
- 检测死锁可以用jstack,jconsole等工具检测。
- 解决办法:
- 当锁的颗粒度变小,锁变多,就更可能导致死锁的现象
-
活锁: 出现在两个线程互相改变对方的结束条件,导致双方都不能结束的现象
- 好比一个线程要某个变量到20结束,就去累加这个数,另一个线程下药变量到0结束,就是自减这个变量,导致双方都没办法结束。
- 解决办法:让执行时间交错运行。
-
饥饿 :线程由于优先级过低,无法得到cpu调度,也不能结束,这种情况就叫饥饿

浙公网安备 33010602011771号