zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Java锁详解[通俗易懂]

JAVA 详解 通俗易懂
2023-06-13 09:12:12 时间

大家好,又见面了,我是你们的朋友全栈君。

文章目录

RocketMQ思维导图,不看会后悔哟 Mysql思维导图分享

上面思维导图可在gongzhonghao回复:扣扣号,获取联系方式后找我免费获得可编辑版本。 后面会继续分享其他思维导图,包括Redis、JVM、并发编程、RocketMQ、RabbtiMQ、Kafka、spring、Zookeeper、Dubbo等等

什么是锁

单线程的情况,下面代码中的count,始终只会被一个线程累加,调用addOne()10次,count的值一定就累加了10。

public class Test { 
   
    // 计数器
    private Integer count = 0;
    
    // 累加操作
    public void addOne() { 
   
        count += 1;
    }

    // 获取计算器的值
    public Integer getCount(){ 
   
        return this.count;
    }
}

而多线程情况下,有一个线程A调用addOne()10次的中间,就很可能会有另外一个线程B也在调用addOne()方法,这就会导致线程A调用getCount()的结果发现count的累加值会大于10。此时线程A就会觉得莫名其妙。所以对线程A来讲,count是线程不安全的。

要保证线程A调用10次,count的累加值也是10,则需要保证线程A在累加时,其他线程先排队等着。这就是多线程间的同步操作。

同步操作的实现,需要给对象关联一个互斥体,这个互斥体就可以叫做锁。不同的锁的实现方式不一样,这个后面会讲到。

锁的实现方式

Java中锁的实现方式有两种:synchronized关键字和并发包中的锁类。

synchronized 关键字是最基本也是最常见的一种同步方式。如:

public void synchronizedTest(){ 
   
  // 同步代码块 
  synchronized (this){ 
   
      // 一些业务操作 
      System.out.println(" synchronizedTest");     
   }
}

synchronized这个同步关键字以前性能不是太理想,在随着不停的优化后,它已经成了同步的首先。

并发包中的锁类基本上都是在JDK1.5以后才有的。如下面的可重入锁:

private ReentrantLock lock = new ReentrantLock();
 public void testLock() { 
   
        // 获取锁
        lock.lock();
        try { 
   
            Thread.sleep(3000);
        } catch (InterruptedException e) { 
   
            e.printStackTrace();
        }
        System.out.println("test ReentrantLock ");
        // 释放锁
        lock.unlock();
  }

synchronized也属于可重入锁。

锁涉及的几个重要概念

死锁

线程之间相互等着对方释放资源,而自己的资源又不释放给别人,这种情况就是死锁。所以,只要其中一线程释放了资源,死锁就会被解除。

重入锁

重入锁指的是,一个线程在拥有了当前资源的锁之后,可以再次拿到该锁而不被阻塞。在后面会讲到synchronized的重入锁原理。

自旋锁

自旋锁指的是,线程在没有获得锁时,不是被直接挂起,而是执行一个空循环(自旋)。默认是循环10次。

自旋锁的目的也就是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。

如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,空循环就会变成浪费系统资源的操作,反而降低了整体性能。所以,自旋锁是不适应锁占用时间长的并发情况的。

自适应自旋锁

自适应自旋锁是对自锁锁的一种优化。当一个线程自旋后成功获得了锁,那么下次自旋的次数就会增加。因为虚拟机认为,既然上次自旋期间成功拿到了锁,那么后面的自旋会有很大几率拿到锁。相反,如果对于某个锁,很少有自旋能够成功获得的,那么后面就会减少自旋次数,甚至省略掉自旋过程,以免浪费处理器资源。

这种锁是默认开启的。

锁消除

锁消除指的是,在编译期间利用“逃逸分析技术”分析出那些不存在竞争却加了锁的代码的锁失效。这样就减少了锁的请求与释放操作,因为锁的请求与释放都会消耗系统资源。

锁消除也是默认开启的。我们知道StringBuffer的append方法是加了锁的,但在下面的情况,它的锁就会失效:

public String test(){ 
   
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < 1000; i++) { 
   
        sb.append(i);
    }
    return  sb.toString();
}

逃逸分析技术,还会将确定不会发生逃逸的对象放在栈内存中而不是堆内存中,所以说,并不是所有的对象都存在堆内存中的。

锁偏向

偏向锁指的是,当第一个线程请求时,会判断锁的对象头里的ThreadId字段的值,如果为空,则让该线程持有偏向锁,并将ThreadId的值置为当前线程ID。当前线程再次进入时,如果线程ID与ThreadId的值相等,则该线程就不会再重复获取锁了。因为锁的请求与释放是要消耗系统资源的。

如果有其他线程也来请求该锁,则偏向锁就会撤销,然后升级为轻量级锁。如果锁的竞争十分激烈,则轻量级锁又会升级为重量级锁。

锁粗化

锁粗化指的是,在编译期间将相邻的同步代码块合并成一个大同步块。这样做可以减少反复申请和释放同一个锁对象导致的系统开销。锁粗化也是默认开启的。

粗化前伪代码:

synchronized(monitor){ 
   
    method1();
}
synchronized(monitor){ 
   
    method2();
}

粗化后伪代码:

synchronized(monitor){ 
   
    method1();
    method2();
}

锁粗化也提醒了我们平时写代码时,尽量不要在循环内使用锁:

// 粗化前
for(int i=0;i<10000;i++){ 
   
    // 这会导致频繁同步代码,无谓的消耗系统资源
    synchronized(monitor){ 
   
        doSomething...
    }
}
// 粗化后
synchronized(monitor){ 
   
    for(int i=0;i<10000;i++){ 
       
        doSomething...
    }
}

类锁和对象锁(重要)

如果你分不清类锁和对象锁,那你在代码中对于锁的使用和分析就很容易出问题。

对象锁占用的资源是对象级别,类锁占有的资源是类级别。

Class A { 
   
    // ==>对象锁:普通实例方法默认同步监视器就是this,
    // 即调用该方法的对象
    public synchronized methodA() { 
   
    }

    public  methodB() { 
        
        // ==>对象锁:this表示是对象锁
        synchronized(this){ 
     
        }
    }

    // ==>类锁:修饰静态方法
    public static synchronized methodC() { 
   
    }

    public methodD(){ 
   
        // ==>类锁:A.class说明是类锁
        synchronized(A.class){ 
   }
    }

    // 普通方法:任何情况下调用时,都不会发生竞争
    public common(){ 
   
    }
}

methodA,和methodB都是对当前对象加锁,即如果有两个线程同时访问同一个对象的methoA或methodB会发生竞争。如果两个线程访问的是不同对象的methodA和methodB则不会发生竞争。

methodC和methodD是对类加锁,即如果两个线程同时访问同一个对象的methodC和methodD会发生竞争,且两个线程同时访问不同对象的methodC和methodD是也会发生竞争。

如果一个线程访问methodA或methodB,另一个线程访问methodC或methodD,则这两个线程不会发生竞争。因为一个是类锁另一个是对象锁。类锁和对象锁是两个不一样的锁,控制着不同的区域,它们互不干扰。

5种类锁示例

Class A { 
   
    // 普通字符串属性
    private String val;
    // 静态属性
    private static Object staticObj;

    // ==>类锁情况1:synchronized修饰静态方法
    public static synchronized methodA() { 
   
    }

    public methodB(){ 
   
        // ==>类锁情况2:同步块里的对象是类
        synchronized(A.class){ 
   }
    }

     public methodC(){ 
   
         // ==>类锁情况3:同步块里的对象是字符串
        synchronized("A"){ 
   }
    }

    public methodD(){ 
   
        // ==>类锁情况4:同步块里的对象是静态属性
        synchronized(staticObj){ 
   }
    }

    public methodE(){ 
   
        // ==>类锁情况5:同步块里的对象是字符串属性
        synchronized(val){ 
   }
    }
}

补充: 两个线程分别访问一个类的静态synchronized和一个静态不加锁方法时,不阻塞。 两个线程分别访问一个类的静态synchronized和一个非静态synchronized方法时,不阻塞。

synchronized实现原理

开始讲一个后面要用到的概念,临界区:被同步保护的代码区域。也就是下面字节码中monitorenter和monitorexit指令之间的区域。

public void synchronizedTest() { 
   
    synchronized (this) { 
   
        System.out.println(" synchronizedTest");
    }
}

上述同步代码块对应的字节码:

在字节码中,位置3处有个monitorenter就是申请锁的指令,位置19处有个monitorexit就是释放锁的指令。

监视锁monitor 是每个对象都有的一个隐藏字段。申请锁成功之后,monitor就会成为当前线程的唯一持有者。线程第一次执行monitorenter指令后,monitor的值由0变为1。当该线程再次遇到monitorenter指令后,就会将monitor继续累加1。这也是synchronized实现重入锁的原理。

我们知道,JVM会有指令重排序的操作。Java会在位置3和位置4之间插入一个获取屏障,在位置18和19之间插入一个释放屏障,这两个屏障保证临界区内的任何操作都不会被指令重排序到临界区之外。加上锁的排他性,临界区内的操作便具有了原子性。

在monitorexit指令后还会插入一个StoreLoad屏障,该屏障保证了monitorenter和monitorexit指令是成对不混乱的,从而保证了synchronized既可并列又可嵌套。

总结

  • 同步操作的实现,需要给对象关联一个互斥体,这个互斥体就可以叫做锁
  • 锁的作用是,保证同一竞争资源在同一时刻只会有一个线程占有
  • Java中锁的实现方式有两种:synchronized关键字和并发包中的锁类
  • 锁的优化策略有:锁消除、锁偏向、自适应自旋锁、锁粗化
  • 尽量不要在循环内使用锁,以减少资源消耗

后面会接着介绍并发包里的几个锁,以及它们之间的区别

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/157054.html原文链接:https://javaforall.cn