前几天再写synchronized的JVM实现时候,想起如果是2个线程同时想获得对象锁,即修改对象头的lock位时候,同时发现对象锁可用,同时修改lock的值,天了噜想想真是太可怕,想解决互斥却在解决方法中也有互斥的问题。后来发现:
任何互斥尝试必须基于一些基础硬件的互斥!
任何互斥尝试必须基于一些基础硬件的互斥!
任何互斥尝试必须基于一些基础硬件的互斥!
重要的话要说3遍!!
那我们来看看硬件的支持吧。
1、中断禁用
在单处理器中,并发进程不能同时执行,只能交替。因此要保证互斥,只需要保证一个进程不被中断就行,这种能力可以通过系统内核启用中断和禁止中断定义的原语实现。
禁用中断
临界区
启用中断
这种方法代价很高,因为处理器只能交替执行程序。还有个问题对于多处理器并没有什么用。
2、专用机器指令
在多处理器中,所有处理器都是共享内存。所以要保证互斥,在硬件级别上,对存储单位的访问要排斥对相同单位的其他访问。基于这一点,处理器设计者设计了一些机器指令,来保证两个动作的完整性,如我们喜闻乐见的compareAndSwap。
机器指令方法使用了忙等待。可能饥饿,导致某些进程无限期进入临界区。最重要的是可能产生死锁,假设进程P1通过CAS进入临界区,但是在临界区的时候被迫中断把处理器让给优先级别更高的P2,如果P2想使用同一资源,由于互斥机制,那么P2将永远无法进入临界区。
对存储单位的访问要排斥对相同单位的其他访问又是怎么实现的呢?举个列子,在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。
软件方法(见下文)和硬件方法都有明显缺陷,我们需要寻找其他机制。
信号量
其基本原理是:两个或者多个进程可以通过简单的信号进行合作,一个进程可以被迫停在某个位置,直到它收到特定的信号。为了达到预期效果,我们可以把信号量看成一个整型变量,在它之上定义3个操作
1)一个信号量初始化非负整数
2)semWaite操作使信号量减一。如果信号量变为负数,则执行semWaite的进程被阻塞。
3)semSignal操作使信号量加一。如果信号量不为负数,则被semWaite阻塞的一个进程解除阻塞
当然semWaite和semSignal都是原语,而且都具有原子性。至于还有二元信号量,管程之类的机制,大家有兴趣可以看看操作系统,下面我们要讲讲软件实现的互斥。
软件实现的互斥当然也要按照基本法,对存储单位的访问要排斥对相同单位的其他访问这个假设肯定是要有的。那一拍脑袋,肯定说设置个变量,每个进程检查变量,如果为true则把值设为false,访问临界区,访问完以后把值设为true,如下:
volatile boolean flag=true;
while(flag)
flag=false;
//访问临界区
flag=true;
再仔细一想,这基本的互斥都做不到,假设P1线程先访问flag,此时flag为true,线程P1还没修改flag值,线程P2访问flag值,然后P1,P2就一起愉快的访问临界区了,可是我们不乐意啊,所以我们要修改程序,分开P1,P2这对小婊砸。
P1:
while(flag)
//访问临界区
flag=true;
P2:
while(!flag)
//访问临界区
flag=false;
这样是分开了P1,P2,但是并不是很优雅,P1,P2总是交替执行,更可怕的是如果P1在访问临界区的时候不小心退出,那么P2将永远不能访问临界区。接下来我就不卖蠢了,直接贴出Peterson算法的java实现。
public class Petersen {
private volatile static boolean flags[];
private volatile static int signal;
public static void main(String args[]) {
flags = new boolean[2];
new Thread(new Runnable() {
public void run() {
while (true) {
flags[0] = true;
signal = 0;
while (flags[1] && signal == 0) {
}
//临界区,do something
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(100l);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getId());
}
//退出临界区
flags[0]=false;
break;
}
}
}).start();
new Thread(new Runnable() {
public void run() {
while (true) {
flags[1] = true;
signal = 1;
while (flags[0] && signal == 1) {
}
//临界区,do something
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(100l);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getId());
}
//退出临界区
flags[1]=false;
break;
}
}
}).start();
}
}
原理是利用一个boolean数组记录各线程的状态,然后用signal来控制来控制同步并发。假设两进程都想进入临界区,把自己flag设置为true,把signal设置为自己线程ID,我们可以看见总有一个线程在while那边忙循环,从而保持了互斥。听说peterson算法很容易推广到N线程,但是试了试3线程,总是不能很好控制,有好心人知道麻烦告知一声。
在看java互斥的实现下我们先来看看java的内存模型(对理解volatile有极大用处):
Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,首先是定义了8种原子操作:
(1) lock:将主内存中的变量锁定,为一个线程所独占
(2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
(3) read:将主内存中的变量值读到工作内存当中
(4) load:将read读取的值保存到工作内存中的变量副本中。
(5) use:将值传递给线程的代码执行引擎
(6) assign:将执行引擎处理返回的值重新赋值给变量副本
(7) store:将变量副本的值存储到主内存中。
(8) write:将store存储的值写入到主内存的共享变量当中。
那synchronized是怎样获取对象锁的呢,当然它可以使用lock原子性操作,也可以用peterse一样的算法,具体实现得看看jvm的源码。以上——
来源:oschina
链接:https://my.oschina.net/u/1383237/blog/477189