Java中的 锁机制

1、synchronized简介

在多线程编程中,synchronized 一直是元老级角色,但它过于重了,但在jdk1.6后对其做了大量的优化操作,它减轻了很多。JAVA SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁。

synchronized 有三种加锁的实现方式

1. 修饰实例方法,作用于当前实例加锁,进入到同步代码前要获取当前实例的锁,

2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 

使用方法如下:

分类具体分类被锁对象伪代码
方法实例方法调用该方法的实例对象public synchronized void method()
方法静态方法类对象Class对象public static synchronized void method()
代码块this调用该方法的实例对象synchronized(this)
代码块类对象类对象synchronized(Demo.class)
代码块任意的实例对象创建的任意对象Object lock= new Object(); synchronized(lock)

synchronized原理分析

我们写一段简单的代码,看看synchronized编译后的字节码: 

public class Test {
    public static void main(String[] args) {
        synchronized (Test.class) {
            int  a = 1;
        }
    }
}

上面的代码demo使用了synchroized关键字,锁住的是类对象

编译之后,切换到Demo1.class的同级目录之后,然后用javap -v Demo1.class查看字节码文件:

 

​ 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带monitor。而monitor是添加Synchronized关键字之后独有的。synchronized同步块使用了monitorenter和monitorexit指令实现同步,这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是【排他】的,也就是说同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。

​ 线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁,而执行monitorexit,就是释放monitor的所有权。

接下来我们从对象头信息中发现一些锁的信息

对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)、数组类型还有一个int类型的数组长度。

我们今天要讲的就是其中的Mark Word

  1. Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。

  2. Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

 

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

 锁升级中涉及的四把锁:

 

  • 无锁:不加锁

  • 偏向锁:不锁锁,只有一个线程争夺时,偏心某一个线程,这个线程来了不加锁。

  • 轻量级锁:少量线程来了之后,先尝试自旋,不挂起线程。

    注:挂起线程和恢复线程的操作都需要转入内核态中完成这些操作,给系统的并发性带来很大的压力。在许多应用上共享数据的锁定状态,只会持续很短的一段时间,为了这段时间去挂起和恢复现场并不值得,我们就可以让后边请求的线程稍等一下,不要放弃处理器的执行时间,看看持有锁的线程是否很快就会释放,锁为了让线程等待,我们只需要让线程执行一个盲循环也就是我们说的自旋,这项技术就是所谓的【自旋锁】。

  • 重量级锁:排队挂起线程

JVM一般是这样使用锁和Mark Word的:

1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在【当前线程】的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,也叫所记录(lock record),同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7,自旋默认10次。

7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞排队。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等)进入阻塞状态,等待将来被唤醒。就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

3. 死锁

 死锁产生的四个必要条件:

  • 1、互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用

  • 2、不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

  • 3、请求和保持,即当资源请求者在请求其他资源的同时保持对原有资源的占有。

  • 4、循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

import java.util.Date;
 
public class LockTest {
   public static String obj1 = "obj1";
   public static String obj2 = "obj2";
   public static void main(String[] args) {
      LockA la = new LockA();
      new Thread(la).start();
      LockB lb = new LockB();
      new Thread(lb).start();
   }
}
class LockA implements Runnable{
   public void run() {
      try {
         System.out.println(new Date().toString() + " LockA 开始执行");
         while(true){
            synchronized (LockTest.obj1) {
               System.out.println(new Date().toString() + " LockA 锁住 obj1");
               Thread.sleep(3000); // 此处等待是给B能锁住机会
               synchronized (LockTest.obj2) {
                  System.out.println(new Date().toString() + " LockA 锁住 obj2");
                  Thread.sleep(60 * 1000); // 为测试,占用了就不放
               }
            }
         }
      } catch (Exception e) {
         e.printStackTrace();
      }
   }
}
class LockB implements Runnable{
   public void run() {
      try {
         System.out.println(new Date().toString() + " LockB 开始执行");
         while(true){
            synchronized (LockTest.obj2) {
               System.out.println(new Date().toString() + " LockB 锁住 obj2");
               Thread.sleep(3000); // 此处等待是给A能锁住机会
               synchronized (LockTest.obj1) {
                  System.out.println(new Date().toString() + " LockB 锁住 obj1");
                  Thread.sleep(60 * 1000); // 为测试,占用了就不放
               }
            }
         }
      } catch (Exception e) {
         e.printStackTrace();
      }
   }
}
线程重入

线程重入是指任意线程在获取到锁之后,再次获取该锁而不会被该锁所阻塞。

public class Test {
    private static final Object M1 = new Object();
    private static final Object M2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (M1){
                synchronized (M2){
                    synchronized (M1){
                        synchronized (M2){
                            System.out.println("hello lock");
                        }
                    }
                }
            }
        }).start();
    }
}

锁上的两个方法: notify  (出让) 和 wait  () 方法

package cn.itcast.myJucLearn;

/**
 * wait 和 notify 方法
 * wait 和 sleep 的区别 在于 wait 方法会释放锁 而 sleep 不会 释放锁
 *
 * 解释下JMM:
 * JMM: Java内存模型
 *
 */
public class WaitTest {

    private static  int num = 10;
    private static final Object MONITOR = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; ; i++) {
                try {
                    Thread.sleep(5); // 等待
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                minus(1,i); // 减去 i
            }
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; ; i++) {
                try {
                    Thread.sleep(10); // 10 秒
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                plus(2,i);
            }
        });

        t1.start();
        t2.start();

        System.out.println("-------------------------------------------------");
    }

    public static void minus(int code,int i){
        // 减去
        synchronized (MONITOR){
            // 
            if(num <= 0){
                try {
                    // 进入等待状态
                    MONITOR.wait(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("这是线程"+code+"--" + --num + "---"+i);
        }
    }

    public static void plus(int code,int i){
        synchronized (MONITOR){
            if(num >= 10){
                MONITOR.notify();
            }

            System.out.println("这是线程"+code+"--" + ++num + "---"+i);
        }
    }
//    public static final Object MONITOR = new Object();
//    public static void main(String[] args) {
//
//        new Thread(() -> {
//            synchronized (MONITOR) {
//                System.out.println("A线程执行了");
//                try {
//                    MONITOR.wait();
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
//                System.out.println("A线程被唤醒了");
//            }
//        }).start();
//
//
//        new Thread(() -> {
//            synchronized (MONITOR) {
//                System.out.println("B线程执行了");
//                try {
//                    Thread.sleep(2000);
//                } catch (InterruptedException e) {
//                    throw new RuntimeException(e);
//                }
//                MONITOR.notify();
//
//                try {
//                    Thread.sleep(2000);
//                } catch (InterruptedException e) {
//                    throw new RuntimeException(e);
//                }
//                System.out.println("B线程被唤醒了 并在两秒后 唤醒了 线程A");
//            }
//        }).start();
//
//
//    }

}

执行结果就是: num的范围始终>=0 且 <=10 

方法总结:

1、Thread的两个静态方法:sleep释放CPU资源,但不释放锁。yield方法释放了CPU的执行权,但是依然保留了CPU的执行资格。这个方法不常用

2、线程实例的方法:

  • join:是线程的方法,程序会阻塞在这里等着这个线程执行完毕,才接着向下执行。

3、Object的成员方法

  • wait:释放CPU资源,同时释放锁。

  • notify:唤醒等待中的线程。

  • notifyAll:唤醒所有等待的线程

线程的退出

(1)使用退出标志,使线程正常退出,也就是当run()方法结束后线程终止。

class Thread01 extends Thread {

    // volatile关键字解决线程的可见性问题
    volatile boolean flag = true;

    @Override
    public void run() {
        while (flag) {
            try {
                // 可能发生异常的操作
                System.out.println(getName() + "线程一直在运行。。。");
            } catch (Exception e) {
                System.out.println(e.getMessage());
                this.stopThread();
            }
        }
    }

    public void stopThread() {
        System.out.println("线程停止运行。。。");
        this.flag = false;
    }
}

public class StopThreadDemo01 {

    public static void main(String[] args) {
        Thread01 thread01 = new Thread01();
        thread01.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread01.stopThread();
    }
}

2. Lock锁

 Lock接口有几个重要方法:

 

// 获取锁  
void lock()   

// 仅在调用时锁为空闲状态才获取该锁,可以响应中断  
boolean tryLock()   

// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁  
boolean tryLock(long time, TimeUnit unit)   

// 释放锁  
void unlock()  

获取锁,两种写法

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){

}finally{
    lock.unlock();   //释放锁
}

Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){

     }finally{
         lock.unlock();   //释放锁
     } 
}else {
    //如果不能获取锁,则直接做其他事情
}
Lock的实现类 ReentrantLock

ReentrantLock,可重入锁。ReentrantLock是实现了Lock接口的类,并且ReentrantLock提供了更多的方法实现线程同步。下面通过一些实例学习如何使用 ReentrantLock。

 可重入锁,之前使用synchronized的案例都可以使用ReentrantLock替代:

public class Ticket implements Runnable{

    private static final ReentrantLock lock = new ReentrantLock();
    private static Integer COUNT = 100;

    String name;

    public Ticket(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        while (Ticket.COUNT > 0) {
            ThreadUtils.sleep(100);
            // 就在这里
            lock.lock();
            try { 
                System.out.println(name + "出票一张,还剩" + Ticket.COUNT-- + "张!");
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Thread one = new Thread(new Ticket("一号窗口"));
        Thread two = new Thread(new Ticket("一号窗口"));
        one.start();
        two.start();
        Thread.sleep(10000);
    }
}
 synchronized和ReentrantLock的区别:

1、区别:

  • Lock是一个接口,synchronized是Java中的关键字,synchronized是内置的语言实现;
  • synchronized发生异常时,会自动释放线程占用的锁,故不会发生死锁现象。Lock发生异常,若没有主动释放,极有可能造成死锁,故需要在finally中调用unLock方法释放锁;
  • Lock可以让等待锁的线程响应中断,使用synchronized只会让等待的线程一直等待下去,不能响应中断
  • Lock可以提高多个线程进行读操作的效率

​ 对于一个应用而言,一般情况读操作是远远要多于写操作的,同时如果仅仅是读操作没有写操作的情况下数据又是线程安全的,读写锁给我们提供了一种锁,读的时候可以很多线程同时读,但是不能有线程写,写的时候是独占的,其他线程既不能写也不能读。在某些场景下能极大的提升效率。

 

public class ReadAndWriteLockTest {
    public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    public static int COUNT = 1;

    public static void main(String[] args) {
        //同时读、写
        Runnable read = () -> {
            ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
            readLock.lock();
            try{
                ThreadUtils.sleep(2000);
                System.out.println("我在读数据:" + COUNT);
            }finally {
                readLock.unlock();
            }
        };

        //同时读、写
        Runnable write = () -> {
            ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
            writeLock.lock();
            try{
                ThreadUtils.sleep(2000);
                System.out.println("我在写数据:" + COUNT++);
            }finally {
                writeLock.unlock();
            }
        };

        for (int i = 0; i < 100; i++) {
            Random random = new Random();
            int flag = random.nextInt(100);
            if(flag > 20){
                new Thread(read,"read").start();
            }else{
                new Thread(write,"write").start();
            }
        }
    }
}
lock锁的原理cas和aqs

本节我们从ReentrantLock的源码,一起解释这些并发编程工具的实现原理,其实很多场景下我们使用synchronized也可以,但是毕竟他不够灵活,是由c++实现的,只能作为关键字来使用,而Java给我们提供了并发编程包,由Doug Lea编写了大量的共性能的线程同步器,而底层的实现原理就是cas和aqs。最后补充一句,能用synchronized实现我们就用synchronized,这是关键字也是jdk团队优化的主要目标。

#(1)并发编程的三大特性

原子性

​ 原子操作定义:原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分(不可中断性)。将整个操作视为一个整体是原子性的核心特征。原子性不仅仅是多行代码,也可能是多条指令。

​ 存在竞争条件,线程不安全,需要转变原子操作才能安全。方式:上锁、循环CAS;上例只是针对一个变量的原子操作改进,我们也可以实现更大逻辑的原子操作。

可见性

我们已经深度的了解过

有序性

volatile:可以保证可见性和有序行

synchronized和Lock:可以保证原子性、可见性、有序性

1)并发编程的三大特性

原子性

​ 原子操作定义:原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分(不可中断性)。将整个操作视为一个整体是原子性的核心特征。原子性不仅仅是多行代码,也可能是多条指令。

​ 存在竞争条件,线程不安全,需要转变原子操作才能安全。方式:上锁、循环CAS;上例只是针对一个变量的原子操作改进,我们也可以实现更大逻辑的原子操作。

可见性

我们已经深度的了解过

有序性

volatile:可以保证可见性和有序行

synchronized和Lock:可以保证原子性、可见性、有序性

#
(2)CAS

​ CAS,compare and swap的缩写,中文翻译成比较并交换,我发现jdk11以后改成了compare and set。

​ 它的思路其实很简单,就是给一个元素赋值的时候,先看看内存里的那个值到底变没变,如果没变我就修改,变了我就不改了,其实这是一种无锁操作,不需要挂起线程,无锁的思路就是先尝试,如果失败了,进行补偿,也就是你可以继续尝试。这样在少量竞争的情况下能很大程度提升性能。

 一个宏观上的例子给大家讲解一下。 


public class CasTest {

    public volatile static int COUNT = 0;

    public synchronized static boolean compareAndSwap(int expect, int update) {
        if (expect == COUNT) {
            COUNT = update;
            return true;
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                ThreadUtils.sleep(1);
                // 模拟自旋
                while (!compareAndSwap(COUNT, COUNT + 1)){
                }
            }).start();
        }
        ThreadUtils.sleep(1000);
        System.out.println(COUNT);
    }
}

但是CAS还是有几个缺点:

  1. ABA问题。当第一个线程执行CAS操作,尚未修改为新值之前,内存中的值已经被其他线程连续修改了两次,使得变量值经历 A -> B -> A的过程。绝大部分场景我们对ABA不敏感。解决方案:添加版本号作为标识,每次修改变量值时,对应增加版本号; 做CAS操作前需要校验版本号。JDK1.5之后,新增AtomicStampedReference类来处理这种情况。
  2. 循环时间长开销大。如果有很多个线程并发,CAS自旋可能会长时间不成功,会增大CPU的执行开销。
  3. 只能对一个变量进行原子操作。JDK1.5之后,新增AtomicReference类来处理这种情况,可以将多个变量放到一个对象中

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值