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

文章目录:

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

2.synchronized的8锁案例

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

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

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

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

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

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

3.字节码角度分析synchronized

3.1 synchronized同步代码块

3.2 synchronized同步实例方法

3.3 synchronized同步静态方法

3.4 synchronized锁的是什么?

5.1 可重入锁之隐式锁synchronized

5.2 可重入锁之显式锁Lock

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,我会慢慢更新的。。。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值