彻底学会Java并发编程——共享模型之管程

39 阅读11分钟

临界区和竞态条件

多个线程访问共享资源,并且多个线程对共享资源进行读写操作的时候,指令发生了交互,这样会出问题。出现问题的这段代码成为临界区。

两个线程对一个变量分别做自增和自减,结果不一定为0。

因为i++的JVM字节码指令为:

getstatic、iconst_1、iadd、putstatic

同理i--的JVM字节码指令为:

getstatic、iconst_1、isub、putstatic

这种情况下如果前一个线程的putstatic在第二个线程getstatic的后面,或者反过来,那么得到的结果就不一定是0。

这种多个线程的指令发生了交互,导致结果无法预测,成为发生了竞态条件。

通过synchronized来解决临界区的竞态条件问题(对象锁):

synchronized简称对象锁

可以使同一时刻只有一个线程能获得对象锁,别的线程没拿到对象锁就会处于阻塞状态,有锁的线程就能安全的执行临界区里的代码。

这是一种互斥的思想。

原理:假设发生竞态问题的线程是t1和t2,t1先拿到了对象锁,此时t2是堵塞状态,就算此时t1分配的时间片用完了,t2仍然是阻塞状态,只有等t1再次拿到了时间片然后执行完synchronized里面的代码的时候,t2才能拿到对象锁然后执行。

原理图示例如下:

重点是上下文切换的时候,被阻塞了。

可以看作用对象锁保证了临界区里的代码的原子性。

面向对象的方法改进锁对象

创建一个room类

对共享资源的保护在类内部里去实现。

synchronized还可以加在成员方法上和加在静态方法上。

加在成员方法上相当于锁住this对象

加在静态方法上相当于锁住类对象

这两种方法锁住的是不同的对象,所以不存在互斥关系。如下图所示:

下面这种情况,虽然有两个this对象,但锁住的是类对象,所以仍然存在互斥关系:

变量的线程安全分析

成员变量和静态变量:

如果没有被共享,则线程安全。

如果被共享了,如果只有读的操作,那么线程安全;如果有读写的操作,这段代码就是临界区,会有线程安全问题。

局部变量

局部变量是线程安全的

但局部变量引用的对象不一定是安全的,当该对象逃离方法的作用范围的时候,需要考虑线程安全。

下面考虑局部变量,局部变量的i++操作的字节码如下所示:

加1和赋值是在一条指令里进行:iinc

局部变量暴露引用的情况

情况如下图代码所示:

方法重写后启动了一个新的线程。

私有修饰符可以保护线程安全:private或final,限制了子类不能覆盖这个方法。所以把上面的class Threadsafe里的方法都改为private或者final,就可以保护线程安全。

常见的线程安全类

String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包下的类

线程安全的类是指,多个线性用类的同一个实例中某一个方法是线程安全的。

但多个方法的组合不是安全的,例子如下所示:

不可变类的线程安全性

String和Integer等都是不可变类,因为其内部的状态不可变,所以他们的方法都是线程安全的。

分析买票问题可能存在的线程安全问题

首先是两个共享资源window和amountlist,检测这俩共享资源调用的方法sell和add是否存在线程安全问题(是否存在读写操作),可以通过添加synchronized来解决。然后看看有没有存在一个实例里方法的组合使用。

转账问题的线程安全分析

此时在一个方法里的共享资源是两个:target和this,所以不能单纯的去锁其中的一个,可以去锁这俩资源的类:account。

monitor概念

java里普通对象的对象头的结构如下:

klass word指向该对象所属的类

Mark word里面的内容如下所示:

Java里数组对象的对象头的结构如下:

synchronized加锁原理:

如上图所示,每个java对象都会关联一个monitor监视器,被加锁的对象(synchronized)的头中的markword会指向这个monitor对象。

此时第一个被执行的线程指向monitor里的owner,剩下的也执行synchronized(obj)的线程会进入entry list里面等待,此时这些线程是阻塞状态。

当thread-2执行完后,owner会随机分配给entrylist里面的线程,然后接着就执行这个线程。

因为每个java对象都关联一个monitor,所以synchronized中必须是同一个对象。

monitor是操作系统提供的。

轻量级锁

在线程没有竞争的情况下会上轻量级锁。

首先初始状态下,每个线程的栈帧里会有一个锁记录(lock record),锁记录里的object reference指向锁定对象,在加锁的时候,尝试用cas(lock record 地址 00)来替换object 里的mark word(hashcode age bias 01)

之后锁定对象后面就变成了00,根据mark word结构表得知,00代表被上了轻量级锁,如下图所示:

cas有两种失败的情况,1是如下图所示,自己线程执行了锁重入,就会在记录一条lock record,作为重入的计数:

导致锁重入的代码如下图所示:method1和method2里面都加锁了,但是method1里面执行了method2。

锁膨胀

cas失败的第二种情况就是,其他的线程持有了该对象的轻量级锁,此时表明有竞争,进入锁膨胀过程。锁膨胀就是把轻量级锁升级为重量级锁。过程如下所示:

此时为object申请monitor锁,然后指向monitor锁。

然后该线程就进入monitor的entrylist等待。

解锁的时候把owner设为null,然后唤醒entrylist的blocked线程。

自旋优化

重量级锁竞争的时候,线程2可以不进入阻塞状态,可以进行自旋优化,在自旋优化期间线程1的锁释放出来了,那么线程2就可以直接加锁,避免阻塞,过程如下所示: 这种自旋优化适合多核cpu,因为自旋会浪费cpu的资源。

如果线程1迟迟没有释放锁,一直在执行同步代码块,那么线程2还是要进入阻塞状态。如下图所示:

偏向锁

轻量级锁每次锁重入的时候,都要进行一些cas操作。

偏向锁在第一次cas的时候把threadid传入对象中,之后锁重入的时候检查ThreadId就好。

过程如下图所示:

一个对象被加了偏向锁,markwork里面后三位变成101;

偏向锁是有延迟的,不会在程序启动的时候就立刻开启,如果想没有延迟,需要设置jvm参数:-XX:BiasedLockingStartupDelay=0 来取消延迟。

-XX:UseBiasedLocking 禁用偏向锁。

偏向锁的倒数10位之前是threadId。

在执行d.hashcode()后会禁用偏向锁,因为偏向锁对象头的前几位不是hashcode。

撤销偏向

有其他线程访问偏向锁的对象的时候,偏向锁转换为轻量级锁。

批量重偏向

对象虽然被多个线程访问,但是偏向了线程1的对象可以偏向线程2。会重置对象的ThreadId。

当撤销偏向锁超过20次后,加锁的时候会重偏向。

批量撤销

当撤销偏向锁阈值超过40次,那么整个类的对象都变为不可偏向的。

锁消除

去除不可能存在竞争的锁,可以提高代码的执行效率。

wait notify

处于owner的线程如果发现执行条件不足就会进入waitset变成waiting状态;waiting状态和blocked状态都是阻塞状态,不占用cpu时间片;此时owner的线程进入waitset后,blocked线程就被唤醒了;waitset里的线程会在owner线程调用notify或者notify all的时候被唤醒,之后就重新进入entrylist和别的线程重新竞争。

Wait notify API的介绍

obj.wait ()让当前获得锁的这个线程一直处于waiting状态。obj.wait(1000)等一秒后自动唤醒(加参数)

obj.notify()唤醒上一个进入waitset的线程

obj.notifyall()唤醒waitset里的所有线程

Wait notify的正确使用姿势

首先讨论wait方法和sleep方法有什么不同

1、wait是object的方法,sleep是thread的方法

2、wait是和synchronized绑定的

3、wait可以释放锁,sleep不能释放锁

相同点是线程都处于TIMEDZ_WAITING状态

synchronized(lock){
while(条件不成立)
{lock.wait;
}//干活
}
//另一个线程
synchronized(lock){
lock.notifyAll();
}

park & unpark

Locksupport类中的方法:

park对应的是wait状态

unpark既可以在park之前调用,也可以在park后调用,效果一样。

特点:

1、unpark和park不需要配合object monitor来使用

2、unpark和park是以线程为单位,等待和唤醒指定的线程,精确度更高;而notify是随机唤醒一个处于等待的线程,notify all是唤醒所有线程,精确度低

3、可以先unpark再park

park和unpark原理如下:

每个线程都有一个自己的parker对象,里面有三个部分组成:_mutex,_cond,_counter

1、当先调用park的时候(unsafe.park())

先检查counter是否为0,如果是0,获得_mutex互斥锁,然后线程进入_cond条件变量阻塞,设置counter为0。

2、再调用unpark的时候

将counter设置为1,再把cond中的线程唤醒,把counter设为0

3、先unpark再park的时候

先把counter设置为1,当park的时候,先检查counter,如果是1,继续运行,然后把counter设置为0。

线程的状态转换

new->runnable 对应1

1:新的线程被start之后就进入runnable,runnable包含很多状态,包括运行状态和阻塞状态,在java的层面看不出来。

runnable<->waiting 对应234

2:wait notify

用synchronized获得对象锁之后,调用wait方法,线程从runnable变成waiting

调用notify、notifyall、interrupt时,如果线程竞争锁成功,从waiting变成runnable

如果竞争锁失败,从waiting变成blocked

3:join方法

当前线程调用t.join方法后,当前线程从runnable进入waiting状态,直到t线程执行完毕,或者调用当前线程的interrupt,当前线程从waiting变成runnable。

3:park unpark方法

调用park会让当前线程从runnable进入waiting状态

调用interrupt、unpark让当前线程从waiting变成runnable。

runnable<->timed_waitng 对应5678

5:wait(long n) notify

调用wait(long n)该线程进入timed_waitng状态

当时间到了n秒、或调用interrupt、notify、notifyall时

竞争锁成功从timed_waitng变成runnable,失败就变成blocked。

6:join(long n)

调用join(long n)该线程进入timed_waitng状态

时间超了n秒,或t线程执行完毕,或者调用当前线程的interrupt,当前线程从timed_waiting变成runnable。

7:sleep(long n)

调用sleep(long n)后,该线程进入timed_waitng状态。

时间超过n秒,当前线程从timed_waiting变成runnable。

8:LockSupport.parkNanos(long nanos) LockSupport.parkUntil(long millis)

调用LockSupport.parkNanos(long nanos)和 LockSupport.parkUntil(long millis) 该线程进入timed_waitng状态

调用unpark或等待超时或interrupt让该线程从timed_waiting变成runnable

runnable<->blocked 对应9

synchronized获取对象锁失败:runnable->blocked

当前锁的代码执行完后,之前blocked的线程竞争,成功的从blocked变成runnable

runnable->terminated 对应10

线程所有代码执行完毕,从runnable->terminated

多把锁

分别锁两个对象,而不是锁this,给不同对象上锁互不干扰。要保证这两个线程互不关联。

坏处:容易发生死锁

结果如下:

两个线程并行。

活跃性

死锁

一个线程同时需要多个锁,可能会出现死锁。

定位死锁

1、使用jconsole工具

2、用jps定位进程id,然后用jstack定位死锁

活锁

互相改变对方的结束条件,两个线程一个加值一个减值,谁也无法结束。

饥饿

线程优先级太低导致一直没有被cpu调度。

顺序加锁的方式可能会导致线程饥饿问题。

reentrantLock(可重入锁)

可中断

可以设置超时时间

可以设置为公平锁

支持多个条件变量

基本语法:

reentrantLock.lock();
try{//临界区
}finally{//释放锁
reentrantLock.unlock;
}

可重入

一个线程首次获得了这个锁,那么有权利再次获得这把锁。

如果不可重入,第二次获得这个锁的时候,自己会被锁住。

可打断

用interrupt打断无限制的等待。

锁超时

trylock:获取不到锁就放弃执行。

用锁超时来解决哲学家就餐的死锁问题。

条件变量