在Java基础我们就曾经学习过线程和锁的概念,还涉及到了死锁、管程、同步机制等等概念,但是我们还没有涉及到更深层次的了解,所以今天我们就深入了解一下锁、管程、还有synchronized关键字等等相关的技术。
悲观锁,乐观锁
首先,我们步入所得概念的时候经常会听到“悲观锁”和“乐观锁”,那么这两种锁是什么意思呢?
- 悲观锁
线程认为自己使用共享数据的时候,一定会存在资源争抢的行为,不管有没有抢到,都会将自己获得的数据进行加锁,这种锁的行为就是悲观锁。
这种机制有比较常见的实现方式:synchronized关键字实现和Lock类的方法lock,后面我会对synchronized关键字进行详细的解说。
- 乐观锁
它和悲观锁相反,认为无论任何时候都不会有其他线程和它争抢共享数据,所以从本质上来说它并没有加锁,常常用于读多写少的场景。
但是乐观锁并非什么都不做,乐观锁是有一种版本控制机制,将获得的数据加上了版本号,如果有其他线程进行资源修改,那么这个共享数据的版本号就会更新,如果线程获得的数据的版本号在处理前后发生了非本线程做的修改,那么说明本次数据不符合线程所期待的(是通过对比自己的数据版本号和现在数据的版本号对比得出),线程放弃这次操作,再次操作。同时还有一种算法-CAS算法也能实现类似的作用,但是这两者的使用场景不尽一致(本人对于CAS算法不太熟悉,如果有兴趣可以看看其他文章)。这种版本控制有没有很熟悉,像不像我们常常用到的git代码仓库,git就是一种分布式版本控制系统。所以乐观锁是比较简单的。
管程
“管程”是指一种多进程/多线程并发编程的实现方式,也被称为进程/线程间的通信和同步机制。这里也就是说,管程并不是一个实体内容,而是一种机制,我们平常所说的获取管程并不是得到了某个实例对象,而是得到了某个凭证,用于控制线程并发时候对数据处理的先后。管程的英文是‘Monitor’,意味监视者,而Java中的synchronized就很好体现这个名称的特性,作为监视者,是需要一个“人”,于是使用synchronized的时候,就需要一个实例对象作为这个“人”,来作为监视功能,同时这个“人”对于同一个共享资源需要是同一个。
但是注意,如果我们需要对线程加上悲观锁,这时候我们用到的是synchronized关键字,并且用在了同步代码块中,我们需要确保这个Monitor是唯一的。
public class test {
public static void main(String[] args) throws InterruptedException, ExecutionException, TimeoutException {
A a = new A();
CompletableFuture.runAsync(() -> a.doA());
CompletableFuture.runAsync(() -> a.doA());
TimeUnit.SECONDS.sleep(3);
}
}
class A{
public static int a = 1;
public void doA(){
while(true){
synchronized(new Object()){
a++;
System.out.println(Thread.currentThread().getName() + ":" + a);
}
}
}
}
/*
ForkJoinPool.commonPool-worker-2:29028
ForkJoinPool.commonPool-worker-2:29029
ForkJoinPool.commonPool-worker-2:29030
ForkJoinPool.commonPool-worker-9:29016
ForkJoinPool.commonPool-worker-9:29032
ForkJoinPool.commonPool-worker-9:29033
*/
这里就可以看出这个悲观锁是没有起作用的。 即便是改成这样子:
public class test {
public static void main(String[] args) throws InterruptedException, ExecutionException, TimeoutException {
A a = new A();
CompletableFuture.runAsync(() -> a.doA());
CompletableFuture.runAsync(() -> a.doA());
CompletableFuture.runAsync(() -> a.updateOb());
TimeUnit.SECONDS.sleep(3);
}
}
class A{
public static int a = 1;
Object ob = new Object();
public void doA(){
while(true){
synchronized(ob){
a++;
System.out.println(Thread.currentThread().getName() + ":" + a);
}
}
}
public void updateOb(){
while(true){
ob = new Object();
}
}
}
/*
ForkJoinPool.commonPool-worker-2:27289
ForkJoinPool.commonPool-worker-2:27290
ForkJoinPool.commonPool-worker-2:27291
ForkJoinPool.commonPool-worker-9:27284
ForkJoinPool.commonPool-worker-9:27293
ForkJoinPool.commonPool-worker-9:27294
*/
从这里可以看出,这个管程在实质上需要的“人”来监视,是同一个对象的地址,而不是一个简单的对象的名称。 所以我们在设计锁的时候,管程所用的锁不能是有变化的。
synchronized
讲完了锁和管程,我们接下来就是对synchronized的解析,比如synchronized的使用,使用地方的差异,底层原理解析。
代码层面解析
在代码宏观层面,我们对synchronized有三种使用方式,三种方式涉及了三种锁,以及不同的底层规则。
对于synchronized有代码块,对象,类三种不同范围的锁。
代码块锁(局部锁)
我们使用代码块锁的时候,通常形式是这样的:
private Object monitor = new Object();
public void test(){
while(){
synchronized(monitor){
//xxx
}
}
}
这里的Monitor可以使用this,但是使用的时候注意避免死锁。
在这三种方式中,同步代码块的范围是最小的。
对象锁
对象锁是将锁加方法上,但是锁住的却是这个对象所有在方法上添加synchroized的方法,其他普通的方法没有影响:
public class test {
public static void main(String[] args) throws InterruptedException, ExecutionException, TimeoutException {
A a = new A();
CompletableFuture.runAsync(() -> a.doA());
CompletableFuture.runAsync(() -> a.doB());
CompletableFuture.runAsync(() -> a.doA());
TimeUnit.SECONDS.sleep(1);
}
}
class A{
public static int a = 1;
Object ob = new Object();
public synchronized void doA(){
while(true){
a++;
System.out.println(Thread.currentThread().getName() + ":" + a);
}
}
public synchronized void doB(){
while(true){
a++;
System.out.println(Thread.currentThread().getName() + ":" + a);
}
}
}
/*
直接运行永远只能看见第一个线程的运行情况
*/
/*
这里doB()方法的sunchroized去掉,就可以看见第二个线程的运行情况,而第三个线程是永远拿不到管程的。
*/
对象锁的范围大于同步代码块,所有加上了synchronized的方法都被锁住,因为这些方法的管程只有一个,就是对象本身,而普通方法不会被影响。
类锁
类锁的范围是这三个里面最大的,他会锁住所有用这个类模板创造的对象:
public class test {
public static void main(String[] args) throws InterruptedException, ExecutionException, TimeoutException {
A a = new A();
A b = new A();
CompletableFuture.runAsync(() -> a.doA());
CompletableFuture.runAsync(() -> b.doA());
TimeUnit.SECONDS.sleep(1);
}
}
class A{
public static int a = 1;
public static synchronized void doA(){
while(true){
a++;
System.out.println(Thread.currentThread().getName() + ":" + a);
}
}
}
//输出的线程名永远只有一个
在static静态方法上使用了synchronized,它会将类给锁住,连带创造的所有对象,这里使用了不同线程调用不同对象的同一static synchronized方法,从结果上观察,它只有一个线程在使用这个方法。
小结
synchronized使用在代码块中,需要任意一个实例对象作为监管者(对象锁使用的监管者是对象本身)。
当使用对象锁的时候,这个对象在不同线程中是不能够同时调用同一个对象中所有加上了
synchronized的方法,对于普通方法没有影响。
当使用类锁的时候,这个类创建的所有对象,不能在同一时刻调用
synchronized修饰的static方法,不管是否在不同线程。
对于日常开发中,悲观锁的范围在不影响业务的前提下,越小越好,越小线程并发效率越高。
同时注意,争抢锁的只能是被 synchronized修饰的东西。
字节码层面解析
同步代码块
利用javap -c test.class我们可以看见类的字节码指令组成。
对于我们的同步代码块,Java使用字节码 monitorenter,monitorexit进行锁的使用。
一般来说,一个 monitorenter对应两个 monitorexit,一个 monitorexit用来正常退出锁,另一个是发生异常的情况下退出锁;如果同步代码块中主动throw了一个异常,那么就只有一个 monitorexit。
对象锁和类锁
使用命令javap -v test.class查看。
对于对象锁和类锁,Java会在使用关键字的方法的flags信息上添加 ACC_SYNCHROIZED
不同的是类锁上还会有一个标志ACC_STATIC。
上面所述三个锁,都持有管程,锁的范围越大,能成为锁的类型越少,类锁的管程是类的Class实例对象,所以只有一个;对象锁的管程是对象本身;同步代码块的管程可以是任意一个对象。那么为什么对象可以成为锁呢?
在Java对象中有一个
ObjectMonitor对象,但是它并不是独立的类,而是用C++实现的一种机制,存放在OpenJDK/hotspot/src/share/vm/runtime/objectMonitor.hpp和OpenJDK/hotspot/src/share/vm/runtime/objectMonitor.cppC++源文件中,有一个属性_owner是用来表示当前持有这个对象的线程,所以一个Java对象可以成为一个锁。
悲观锁到此结束,还请各位多多支持。