多线程锁的基本概念

本文介绍了悲观锁和乐观锁的概念,以及它们在Java中的实现。悲观锁在读写操作时先加锁,适合写操作频繁的场景;乐观锁假设很少发生冲突,在更新数据时检查是否被修改,常通过版本号机制实现,适合读操作多的场景。文章还探讨了synchronized的使用,包括对象锁和类锁的区别,并展示了ReentrantLock作为可重入锁的公平性和非公平性的特点。此外,提到了死锁的产生条件和避免策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

乐观锁和悲观锁

悲观锁介绍

认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候都会先加锁,确保数据不会被其他线程进行修改,synchronizedLock的实现类都是悲观锁

常见的应用场景:适合写操作多的场景,先加锁可以保证写操作时的数据正确

乐观锁介绍

认为自己在使用数据的时候不会有其他线程对数据进行修改,所以不会添加锁。在Java中是通过无锁编程来实现,只是在更新数据的时候去判断,之前有没有线程更修女了这个数数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入,如果这个数据已经被其他线程更新,则根据不同的实现方式执行不同的曹祖,比如放弃修改或者重新尝试抢占锁资源等等,判断规则可以使用版本号机制Version,最常用的是CSA算法,Java原子类中的递增操作就是通过CSA自旋实现的

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


synchronized

对象锁
public class SynchronizesTest {
private final Logger log= LoggerFactory.getLogger(SynchronizesTest.class);

class Phone1{
public synchronized void sendEmail(){
try{
 TimeUnit.SECONDS.sleep(3);
}catch(Exception e){
 log.error(e.getMessage());
}
log.info("发送邮件");
}

public synchronized void sendSMS(){
log.info("发送短信");
}

public void hello(){
log.info("hello world");
}
}
/**
  * 演示调用同一个对象中的两个synchronized方法所产生的问题
  */
 @Test
 public void test1() throws InterruptedException {
     Phone1 phone1=new Phone1();
     new Thread(phone1::sendEmail).start();
     new Thread(phone1::sendSMS).start();
     //主线程暂停一会避免子线程还没结束主线程就执行完了导致接收不到子线程的执行结果
     TimeUnit.SECONDS.sleep(5);
 }

 @Test
 public void test2() throws InterruptedException {
     Phone1 phone1=new Phone1();
     new Thread(phone1::sendEmail).start();
     new Thread(phone1::hello).start();
     TimeUnit.SECONDS.sleep(5);
 }
}

按照经验上来说,开启了多线程,那么每个方法之间是互不干扰的,但是Test1的执行结果却是需要等待执行发送邮件功能完成之后才能进行发送短信,这是因为当一个类中的多个方法添加了synchronized后,那么当调用其中一个时,则锁定当前this对象,该对象中的其他synchronized方法必须要等到前面的调用完成才能执行,即同一时刻内只能有一个线程访问该对象的这些synchronized方法,但是调用其他普通的方法并不影响,例如Test2中的调用。这种锁的方式就是**对象锁**,针对当前对象进行上锁。


类锁

新建一个Comopute类演示类锁

public class Compute {
   private final static Logger log= LoggerFactory.getLogger(Compute.class);

   public synchronized static void sendEmail(){
       try{
           TimeUnit.SECONDS.sleep(3);
           log.info("发送邮件");
       }
       catch(Exception e){
           e.printStackTrace();
       }
   }

   public synchronized  static void sendSMS(){
       log.info("发送短信");
   }

   public synchronized void sendWinxin(){
       log.info("发送微信");
   }

   public void hello(){
       log.info("hello world");
   }
}

compute类一共有两个静态同步方法,一个普通同步方法,和一个普通方法

//测试使用静态同步方法是否会影响普通同步方法
@Test
public void tes1(){
   Compute compute=new Compute();

  	new Thread(()->{
       compute.sendEmail();
   }).start();

   new Thread(()->{
       compute.sendWinxin();
   }).start();
}

//使用不同对象调用两个不同的静态同步方法是否会有相互影响
@Test
public void test2(){
   Compute compute1=new Compute();

   Compute compute2=new Comopute();

   new Thread(()->{
       compute1.sendEmail();
   }).start();

   new Thread(()->{
       compute2.sendSMS();
   }).start();
}

由Test1和Test2的运行结果可得,调用静态同步方法时,该类中的其他静态同步方法需要等待上一个执行完成才能开始执行,但是对该类中的普通同步方法没有影响。,静态同步方法锁定的是该类,即同一时刻内只能有一个线程调用这些静态同步方法。具体的实例对象this和唯一模板class对象,这两把锁是不同的对象,所以静态同步方法和普通2同步方法之间是不构成竞争条件的,需要注意区分类锁和对象锁的本质


总结

对于普通同步方法,锁的是当前的实例对象,通常指this,所有的普通同步方法都是同一把锁

对于静态同步方法,锁的是当前类的Class对象,所有的静态同步方法都是同一把锁

对于同步代码块,锁的是synchronized括号内的对象


synchronized三种常见实现方式
  • 作用于实例方法,当前实例对象加锁,进入其他同步实例方法前需要获得当前对象实例的锁
  • 作用于代码块,对括号内的配置的对象进行加锁
  • 作用于静态方法,当前类加锁,进入其他静态代码前需要获得当前类的锁

synchronized实现原理

对于同步代码块通过使用monitorenter持有锁,当代码正常结束之后使用monitorexit释放锁,同时为了保证在发生异常时也能够释放锁,通常会是一个monitorenter对应两个monitorexit,当然如果在同步代码块中手动抛出异常,那么反编译之后看到的字节码文件就会是一个monitorenter对应一个moniotorexit

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

对于静态同步方法。原理和普通同步方法类似,不过会额外添加ACC_STATIC标志该方法是否为静态方法

synchronized实际上属于是可重入锁,可以查看可重入锁实现原理


公平锁和非公平锁

通常使用ReentrantLock类来实现,公平锁是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买票,后来的人排队,ReentrantLock lock=new ReentrantLock(true)表示公平锁非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者是线程饥饿状态(某个线程一直得不到锁)ReentrantLock lock=new ReentrantLock()默认情况下就是非公平锁


为什么默认使用的是非公平锁

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


适用场景

如果为了更高的吞吐量,很显然非公平锁时比较合适的,因为节省了很多线程切换导致的时间差,吞吐量自然就上去了,否则就使用公平锁,大家都有机会获取到锁

案例演示

public class ReentrantLockTest {
    private final Logger log= LoggerFactory.getLogger(ReentrantLockTest.class);

    class train{
        private  int remainder=60;

        private final ReentrantLock lock=new ReentrantLock();


        public void sell(){
            try{
                lock.lock();
                for(int i=0;i<20;i++){
                    if(remainder>=0){
                        log.info("当前线程{}获取锁开始卖票,剩余票数:{}",Thread.currentThread().getName(),remainder--);
                    }
                }
            }
            finally {
                lock.unlock();
            }
        }
    }

    /**
     * 使用sell方法演示公平锁非公平所
     */
    @Test
    public void test2() throws InterruptedException {
        train train=new train();

        new Thread(()->{
            train.sell();
        },"A").start();

        new Thread(()->{
            train.sell();
        },"B").start();

        new Thread(()->{
            train.sell();
        },"C").start();

        TimeUnit.SECONDS.sleep(10);
    }
}

通过修改ReentrantLock的在构造时传入的true或者是false,可以看到,如果使用的是非公平锁的话,A,B,C三个线程不一定都能够有机会去进行售票的,或者有的线程卖的多有的卖的少,但是使用公平锁的话,就可以看到三个线程卖得票数是一样的,都是20张


可重入锁(递归锁)

可重入锁时指在同一个线程在外层方法获取锁的时候在进入该线程的内层方法会自动获取锁(前提是锁对象是同一个对象),不会因为之前已经获取过还没释放而阻塞,在一个synchronized方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,时永远可以得到锁的,例如一个使用synchronized修饰的方法是一个递归方法,在进入该方法之后又调用了自身,这种情况就是通过外层方法进入之后能够自动获取内层方法的锁,在Java中,synchronizedReentrantLock都是可重入锁,可重入锁又分为隐式和显式,synchronized属于隐式锁ReentrantLock属于显式锁

//使用synchronized演示隐式可重入锁
@Test
public void test(){
   Object object=new Object();

        new Thread(()->{
            //此处是对象锁,针对于object
            synchronized (object){
                log.info("线程{} -----------外层调用",Thread.currentThread().getName());
                //外层已经加了对象锁,此刻的锁和外层的是同一个对象锁,所以可进入
                synchronized (object){
                    log.info("线程{} -----------中层调用",Thread.currentThread().getName());
                    synchronized (object){
                        log.info("线程{} -----------内层层调用",Thread.currentThread().getName());
                    }
                }
             
        },"t1").start();

        TimeUnit.SECONDS.sleep(3); 
}

同步方法的例子和上面的差不多,可以自己新建一个测试方法,多个同步方法之间层层调用

/**
* 演示使用ReentrantLock实现显示的可重入锁
*/
@Test
public void test1() throws InterruptedException {
    ReentrantLock lock=new ReentrantLock();

    new Thread(()->{
        try{
            lock.lock();
            log.info("线程 {} --------外层调用",Thread.currentThread().getName());
            try{
                lock.lock();
                log.info("线程 {} --------内层调用",Thread.currentThread().getName());
            }finally {
                lock.unlock();
            }
        }finally {
            lock.unlock();;
        }
    },"t1").start();

    TimeUnit.SECONDS.sleep(3);
}

可重入锁实现原理

为什么任何对象都可以成为锁?:因为每个对象都存在着一个monitor与之关联,当一个monitor被某个线程持有后,它便处于锁定状态,在Java虚拟机中,monitor是由ObjectMonitor实现的,其主要数据结构如下:

在这里插入图片描述

每个锁对象拥有一个锁计数器和一个指向该锁的线程的指针,通过反编译class文件可知,当执行monitorenter时,如果目标锁对象的技术器为零,那么说明他没有被其他线程所持有,JAVA虚拟机会将该锁对象的持有线程设置为当前线程,并且将计数器加一,在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么JAVA虚拟机可以将计数器累加一,否则需要等待,直至持有线程释放锁,当执行monitorexit时,JAVA虚拟机将锁对象的技术器减一,计数器为零代表锁已被释放

整个执行流程如下图所示:

image-20221025092523460


自旋锁

CAS理论:compare and swap的缩写,中文翻译就是比较并交换,实现并发算法时常用的一种技术,他包含三个操作:内存位置,预期原值,更新值。执行CAS操作时,将内存位置的值和预期原值进行比较,如果相匹配,那么处理器会自动将该位置的值更新为新值,如果不匹配,处理器不做任务操作,多线程同时执行CAS操作只有一个会成功。

自旋锁是在CPU层面上的锁,不涉及到内核态和用户态之间的切换,所以相对来水性能会计较高,但是他只适用于执行较快的操作,这样才不会出现大量的线程在外进行自旋从而导致CPU空转。具体的实现在:CAS篇会有详细讲解


死锁

死锁产生的四个必要条件
  • 互斥条件

    资源独占的且排他使用,进程互斥使用资源,即任何时刻一个资源只能给一个进程使用,其他进行若申请一个资源,而该资源被另一个进程占有时,则申请者等待直到资源被释放

  • 不可剥夺条件

    进程锁获得的资源在未使用完毕时,不能被其他进程所剥夺,而只能由获得该资源的进程进行资源的释放

  • 请求和保持条件

    进程每次申请它所需要的一部分资源,在申请新的资源的同时,继续占有已分配的资源

  • 循环等待条件

    在发生死锁时,必然存在一个进程等待队列{P1,P2,P3…},其中P1等待P2占有的资源,P2等待P3占有的资源,… Pn等待P1占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个进程申请,也就是前一个进程占有后一个进行所申请的资源

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

public class DeadLockTest {
    private static final Logger log= LoggerFactory.getLogger(DeadLockTest.class);

    public static void main(String[] args) {
        Object objectA=new Object();
        Object objectB=new Object();

        new Thread(()->{
            synchronized (objectA){
                log.info("线程{}持有objectA对象锁,希望获得objectB对象锁",Thread.currentThread().getName());
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (objectB){
                    log.info("线程{}成功获得B对象锁",Thread.currentThread().getName());
                }
            }
        },"A").start();

        new Thread(()->{
            synchronized (objectB){
                log.info("线程{}持有objectB对象锁,希望获得objectA对象锁",Thread.currentThread().getName());
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (objectA){
                    log.info("线程{}成功获得A对象锁",Thread.currentThread().getName());
                }
            }
        },"B").start();
    }
}

以上就是死锁的演示案例,线程A持有对象A锁,并且希望获得对象B锁,但是对象B锁被线程B锁持有,线程B又希望获得对象A锁,导致了两个进程两两互相等待其他线程释放锁而造成死锁

死锁检测

利用Java的原生命令 jsp -l 打印所有正在运行的Java进程,然后通过jstack 进程号命令检测指定线程号的线程是否存在死锁

image-20221024221903796

image-20221024221955807

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值