Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)

Posted 宋子浩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)相关的知识,希望对你有一定的参考价值。

文章目录:

1.从乐观锁和悲观锁开始说起

2.synchronized的8锁案例

2.1 第一种情况:两个线程锁的是同一个实例对象

2.2 第二种情况:第一个线程的逻辑中添加sleep睡眠

2.3 第三种情况:第二个线程执行的是无锁方法

2.4 第四种情况:两个线程锁的是两个不同的实例对象

2.5 第五种情况:两个线程锁的是同一个类对象

2.6 第六种情况

2.7 第七种情况:一个线程锁实例对象,一个线程锁类对象

2.8 第八种情况

3.字节码角度分析synchronized

文件反编译技巧

3.1 synchronized同步代码块

3.2 synchronized同步实例方法

3.3 synchronized同步静态方法

3.4 synchronized锁的是什么?

4.公平锁和非公平锁

5.可重入锁

5.1 可重入锁之隐式锁synchronized

5.1.1 针对同步代码块

5.1.2 针对同步方法

5.2 可重入锁之显式锁Lock

6.死锁及排查

7.总结 


1.从乐观锁和悲观锁开始说起

  • 悲观锁:悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

                     悲观锁的实现方式:① synchronized关键字;

                                                      ② Lock接口的实现类都是悲观锁。

                     适合写操作多的场景,先加锁可以保证写操作时数据正确。显示的锁定之后再操作同步资源。

public synchronized void method() 
    //加锁之后的业务逻辑


Lock lock = new ReentrantLock();

public void method2() 
    lock.lock();
    try 
        //加锁之后的业务逻辑
     finally 
        lock.unlock();
    
  • 乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作

                     乐观锁的实现方式:① 版本号机制Version。(只要有人提交了就会修改版本号,可以解决ABA问题)

                                           ABA问题:再CAS中想读取一个值A,想把值  A变为C,不能保证读取时的A就是赋值时的A,中间可能有个线程将A变为B再变为A。

                                           解决方法:Juc包提供了一个AtomicStampedReference,原子更新带有版本号的引用类型,通过控制版本值的变化来解决ABA问题。
                                                      ② 最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

                      适合读操作多的场景,不加锁的性能特点能够使其操作的性能大幅提升。

AtomicInteger atomicInteger = new AtomicInteger(1);
atomicInteger.incrementAndGet();

2.synchronized的8锁案例

首先,我们可以看一下阿里巴巴Java开发手册中,关于锁的强制性要求。

2.1 第一种情况:两个线程锁的是同一个实例对象

这里我能使用 Lambda 表达式的原因是,Phone类中的这两个实例方法是无参、无返回值的,和Runnable中的run方法一致,所以直接方法引用是OK的。

两个线程锁的都是我 new 的同一个对象 phone,所以当第一个线程去发邮件的时候就拿到了 phone 对象这把锁,此时第二个线程就拿不到了,只能等待第一个线程执行完释放锁,它才可以去发短信。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public void sendEmail() 
        synchronized (this) 
            System.out.println("-----发送邮件");
        
    

    public void sendSMS() 
        synchronized (this) 
            System.out.println("-----发送短信");
        
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();

        new Thread(phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(phone::sendSMS, "b").start();
    

2.2 第二种情况:第一个线程的逻辑中添加sleep睡眠

和第一种情况不同的是:当第一个线程拿到 phone 对象锁之后,在发邮件的过程中,sleep睡眠了2秒。但是执行结果和第一种情况是一样的。

原因就是 sleep 方法并不会释放锁,只是让线程暂定一段时间,一段时间过后线程照常执行(不要interrupt打断。。。)。

某一个时刻内,只能有唯一的一个线程去访问这些针对于实例对象的synchronized方法,锁的是当前对象this,被锁定后,其它的线程都不能 进入到当前对象的其他synchronized方法。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public void sendEmail() 
        synchronized (this) 
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            System.out.println("-----发送邮件");
        
    

    public void sendSMS() 
        synchronized (this) 
            System.out.println("-----发送短信");
        
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();

        new Thread(phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(phone::sendSMS, "b").start();
    

2.3 第三种情况:第二个线程执行的是无锁方法

这种情况,虽然第一个线程拿到 phone 对象锁去发邮件了,中间睡了2秒不会释放锁对象。但是第二个线程的任务是 hello,这个方法并没有任何锁机制,它并不会和synchronized修饰的同步方法、代码块发生争抢,所以两个线程你干你的、我干我的。   由于线程a睡眠了,所以这里线程b就先执行完毕。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public void sendEmail() 
        synchronized (this) 
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            System.out.println("-----发送邮件");
        
    

    public void sendSMS() 
        synchronized (this) 
            System.out.println("-----发送短信");
        
    

    public void hello() 
        System.out.println("-----hello");
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();

        new Thread(phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(phone::hello, "b").start();
    

2.4 第四种情况:两个线程锁的是两个不同的实例对象

两个线程锁的实例对象不同,线程a锁了phone,线程b锁了phone2,那么这里就算执行的是 synchronized 同步方法、代码块,也互不干扰,因为这是两把锁。

又因为线程a中间睡了2秒,所以线程b先执行完。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public void sendEmail() 
        synchronized (this) 
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            System.out.println("-----发送邮件");
        
    

    public void sendSMS() 
        synchronized (this) 
            System.out.println("-----发送短信");
        
    

    public void hello() 
        System.out.println("-----hello");
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();
        Phone phone2 = new Phone();

        new Thread(phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(phone2::sendSMS, "b").start();
    

2.5 第五种情况:两个线程锁的是同一个类对象

这里将上面的实例方法修改成了静态方法,一经修改,那么这两个方法就是类级别了,synchronized锁的是 Phone 这个类,那么无论开了多少个线程,当第一个线程获得类锁之后,其他的线程都无法再拿到这个类锁。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public static void sendEmail() 
        synchronized (Phone.class) 
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            System.out.println("-----发送邮件");
        
    

    public static void sendSMS() 
        synchronized (Phone.class) 
            System.out.println("-----发送短信");
        
    

    public void hello() 
        System.out.println("-----hello");
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();
        Phone phone2 = new Phone();

        new Thread(Phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(Phone::sendSMS, "b").start();
    

2.6 第六种情况

和第五种情况的区别是:声明了两个Phone对象(phone、phone2),但是原理和第五种情况是一样的,因为这里是类锁。

2.7 第七种情况:一个线程锁实例对象,一个线程锁类对象

两个线程锁的对象不同,第一个线程锁的类对象,去发邮件,中间睡了2秒,执行稍慢;第二个线程锁的phone实例对象,去发短信;二者是互不干扰的,你干你的,我干我的。  由于线程a睡眠了,所以线程b先完成执行。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
class Phone 
    public static void sendEmail() 
        synchronized (Phone.class) 
            try 
                TimeUnit.SECONDS.sleep(2);
             catch (InterruptedException e) 
                e.printStackTrace();
            
            System.out.println("-----发送邮件");
        
    

    public void sendSMS() 
        synchronized (this) 
            System.out.println("-----发送短信");
        
    

    public void hello() 
        System.out.println("-----hello");
    


public class Lock8 

    public static void main(String[] args) 
        Phone phone = new Phone();
        Phone phone2 = new Phone();

        new Thread(Phone::sendEmail, "a").start();

        try 
            TimeUnit.MILLISECONDS.sleep(200);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        new Thread(phone::sendSMS, "b").start();
    

2.8 第八种情况

和第七种情况的区别是:Phone对象声明了2个,但是和第七种情况的原理仍然一样,一个锁了类对象、一个锁了实例对象,二者不产生竞争条件。


3.字节码角度分析synchronized

先介绍两个东西:

文件反编译技巧

  • 文件反编译javap -c ***.class文件反编译,-c表示对代码进行反汇编

  • 假如需要更多信息 javap -v ***.class ,-v即-verbose输出附加信息(包括行号、本地变量表、反汇编等详细信息)

3.1 synchronized同步代码块

以下面这段代码为例,在IDEA中编译运行之后,会生成 .class 字节码文件,我们找到它所在的目录,cmd打开。

package com.juc.lock;

/**
 *
 */
public class LockSyncDemo 

    Object object = new Object();

    public void method1() 
        synchronized (object) 
            System.out.println("----- synchronized code block");
        
    

    public static void main(String[] args) 

    

synchronized同步代码块,实现使用的是 moniterenter 和 moniterexit 指令(moniterexit可能有两个,是因为程序要完完全全的确保你能够释放掉占有的锁对象,可能第一次 exit 没有释放掉,你的程序中有一些错误什么的,所以在后面还会有第二个 exit),底层实际上就是靠 这两个指令来确保锁的获取和释放。

那一定是一个enter两个exit吗?(不一样,如果主动throw一个RuntimeException,发现一个enter,一个exit,还有两个athrow)

3.2 synchronized同步实例方法

package com.juc.lock;

/**
 *
 */
public class LockSyncDemo 

    public synchronized void m2() 
        System.out.println("------hello synchronized m2");
    

    public static void main(String[] args) 

    

使用  javap -v LockSyncDemo.class,更详细的查看字节码文件。

调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置。如果设置了,执行线程会将先持有monitor锁,然后再执行方法,最后在方法完成无论是正常完成还是非正常完成)时释放monitor。

3.3 synchronized同步静态方法

package com.juc.lock;

/**
 *
 */
public class LockSyncDemo 

    public static synchronized void m2() 
        System.out.println("------hello synchronized m2");
    

    public static void main(String[] args) 

    

所以它这里就是通过这两个东西去判断的,如果同时具备了 ACC_STATIC、ACC_SYNCHRONIZED,那就是类锁,只具备 ACC_SYNCHRONIZED,那就是对象锁。  

3.4 synchronized锁的是什么?

管程:Monitor(监视器),也就是我们平时说的锁。监视器锁

信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。 管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管理。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。


4.公平锁和非公平锁

非公平锁:默认是非公平锁。非公平锁可以插队,买卖票不均匀。

                  是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或饥饿的状态(某个线程一直得不到锁)

公平锁:ReentrantLock lock = new ReentrantLock(true);     是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买后来的人在队尾排着,                      这是公平的。


为什么会有公平锁/非公平锁的设计?为什么默认是非公平?

  • 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU 的时间片,尽量减少 CPU 空闲状态时间。
  • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。

什么时候用公平?什么时候用非公平?

  •  如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了;否则那就用公平锁,大家公平使用。

来看下面的代码案例。

package com.juc.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 资源类,模拟3个售票员卖完50张票
 */
class Ticket 
    private int number = 50;

    Lock lock = new ReentrantLock(); //默认就是非公平锁

    public void sale() 
        lock.lock();
        try 
            if(number > 0) 
                System.out.println(Thread.currentThread().getName() + "卖出第:\\t" + (number--) + "\\t 还剩下: " + number);
            
         finally 
            lock.unlock();
        
    


public class SaleTicketDemo 
    public static void main(String[] args) 
        Ticket ticket = new Ticket();

        new Thread(() -> 
            for (int i = 0; i <55; i++) 
                ticket.sale();
            
        ,"a").start();
        new Thread(() -> 
            for (int i = 0; i <55; i++) 
                ticket.sale();
            
        ,"b").start();
        new Thread(() -> 
            for (int i = 0; i <55; i++) 
                ticket.sale();
            
        ,"c").start();
    

下面的执行结果,没有截完整,最后全部都是线程b卖的票,这里就可以看到,严重的非公平锁了,压根就没有线程c的事。 

将上述代码修改为公平锁是很简单的,在 ReentrantLock 的构造方法中传入一个布尔值 true就可以了。

至于原因,我在后面会为大家讲解,就是AQS!!!

Lock lock = new ReentrantLock(true); //公平锁


5.可重入锁

可重入锁又名递归锁:是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。

如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

  • 可:可以

  • 重:再次

  • 入:进入

  • 锁:同步锁

  • 进入什么:进入同步域(即同步代码块/方法或显示锁锁定的代码)

  • 一句话:一个线程中的多个流程可以获取同一把锁,持有这把锁可以再次进入。自己可以获取自己的内部锁。

5.1 可重入锁之隐式锁synchronized

指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的。

5.1.1 针对同步代码块

package com.juc.lock;

/**
 *
 */
public class ReEntryLockDemo 
    public static void main(String[] args) 
        final Object obj = new Object();

        new Thread(() -> 
            synchronized (obj) 
                System.out.println(Thread.currentThread().getName() + " 外层调用....");
                synchronized (obj) 
                    System.out.println(Thread.currentThread().getName() + " 中层调用....");
                    synchronized (obj) 
                        System.out.println(Thread.currentThread().getName() + " 内层调用....");
                    
                
            
        , "t1").start();
    

5.1.2 针对同步方法

package com.juc.lock;

/**
 *
 */
public class ReEntryLockDemo 
    public synchronized void m1() 
        System.out.println(Thread.currentThread().getName() + " --- come in");
        m2();
        System.out.println(Thread.currentThread().getName() + " --- end");
    

    public synchronized void m2() 
        System.out.println(Thread.currentThread().getName() + " --- come in");
        m3();
    

    public synchronized void m3() 
        System.out.println(Thread.currentThread().getName() + " --- come in");
    

    public static void main(String[] args) 
        ReEntryLockDemo obj = new ReEntryLockDemo();
        new Thread(obj::m1, "t1").start();
    

针对上面两个案例,为什么可以这样重入锁呢?

  • 首次加锁:当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
  • 重入:在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。
  • 释放锁:当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。

5.2 可重入锁之显式锁Lock

package com.juc.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 *
 */
public class ReEntryLockDemo 
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) 
        new Thread(() -> 
            lock.lock();
            try 
                System.out.println(Thread.currentThread().getName() + " --- 外层调用");
                lock.lock();
                try 
                    System.out.println(Thread.currentThread().getName() + " --- 内层调用");
                 finally 
                    lock.unlock();
                
             finally 
                lock.unlock();
            
        , "t1").start();
    

上面的案例中,切记:你lock了几次,对应的就要 unlock 几次,否则线程的获取锁、释放锁次数是无法对应的,程序就炸了。 


6.死锁及排查

死锁:是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。   a跟b两个资源互相请求对方的资源。

死锁产生的原因:系统资源不足;进程运行推进的顺序不合适;资源分配不当。

package com.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 *
 */
public class DeadLockDemo 
    public static void main(String[] args) 
        final Object objectA = new Object();
        final Object objectB = new Object();

        new Thread(() -> 
            synchronized (objectA) 
                System.out.println(Thread.currentThread().getName() + " --- 获取到了A锁,希望获取B锁");
                try 
                    TimeUnit.SECONDS.sleep(1);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                synchronized (objectB) 
                    System.out.println(Thread.currentThread().getName() + " --- 获取到了B锁");;
                
            
        , "A").start();

        new Thread(() -> 
            synchronized (objectB) 
                System.out.println(Thread.currentThread().getName() + " --- 获取到了B锁,希望获取A锁");
                try 
                    TimeUnit.SECONDS.sleep(1);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                synchronized (objectA) 
                    System.out.println(Thread.currentThread().getName() + " --- 获取到了A锁");
                
            
        , "B").start();
    

并不是说我们的程序出现了上图中的小红方块,没有结束就一定是死锁,我也可以写一个  while(true)  死循环卡在这里。

所以我们需要对是否是死锁进行排查。 

  • jps -l 查看当前进程运行状况

  • jstack 进程编号 查看该进程信息

或者我们可以  win + R,输入 jconsole。


7.总结 

这篇文章是关于锁的,是入门级别的文章,后续还会有更深入、更底层的JUC,我会慢慢更新的。。。 

以上是关于Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)的主要内容,如果未能解决你的问题,请参考以下文章

Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)

Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)

Java并发编程 JUC中的锁

Java——聊聊JUC中的CompletableFuture

Java——聊聊JUC中的CompletableFuture

Java——聊聊JUC中的CompletableFuture