一、synchronized

在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对synchronized 进行了各种优化之后,有些情况下它就并不那么重。

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){ }

1.1、实现原理

线程在获取锁的时候,实际上就是获得一个监视器对象(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。
  3. Mark Word在不同的锁状态下存储的内容不同,在64位JVM中是这么存的:

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

锁升级中共涉及到四把锁

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

抢锁的过程如下

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。在这个状态下,未抢到锁的线程都会被阻塞排队。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等)进入阻塞状态,等待将来被唤醒。就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

二、死锁

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

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

  • 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  • 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  • 请求和保持,即当资源请求者在请求其他资源的同时保持对原有资源的占有。
  • 循环等待,即存在一个等待队列: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 Test1 {
    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();
    }
}

四、wait & notify

public class ThreadTest2 {
    private static final Object MONITOR = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            ThreadUtils.sleep(5);
            thread1();
        });
        Thread t2 = new Thread(() -> {
            ThreadUtils.sleep(10);
            thread2();
        });
        t1.start();
        t2.start();
    }

    public static void thread1(){
        synchronized (MONITOR){
            try {
                System.out.println("线程1开始等待");
                MONITOR.wait(2000);
                System.out.println("线程1被唤醒");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void thread2(){
        synchronized (MONITOR){
            ThreadUtils.sleep(1500);
            MONITOR.notify();
            System.out.println("线程2唤醒线程1");
        }
    }
}

线程实例的方法:

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

Object的成员方法

  • wait:释放CPU资源,同时释放锁。
  • notify:唤醒等待中的线程。
  • notifyAll:唤醒所有等待的线程

五、线程的退出

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

class ThreadA 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;
    }
}

使用interrupt()方法中断线程,会报异常错误,我们只要将异常抛出即可

public class ThreadTest2 {
    private static final Object MONITOR = new Object();

    public static void main(String[] args) {
    Thread t1 = new Thread(()->{
        ThreadUtils.sleep(1000000);
    });
    t1.start();
    t1.interrupt();
    System.out.println("程序中断");
    }
}

如果线程处于类似while(true)运行的状态,interrupt()方法无法中断线程。

六、LockSupport

LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,当然阻塞之后肯定得有唤醒的方法。

public static void park(Object blocker); // 暂停当前线程
public static void parkNanos(Object blocker, long nanos); // 暂停当前线程,不过有超时时间的限制
public static void parkUntil(Object blocker, long deadline); // 暂停当前线程,直到某个时间
public static void park(); // 无期限暂停当前线程
public static void parkNanos(long nanos); // 暂停当前线程,不过有超时时间的限制
public static void parkUntil(long deadline); // 暂停当前线程,直到某个时间
public static void unpark(Thread thread); // 恢复当前线程
public static Object getBlocker(Thread t);

这儿parkunpark其实实现了waitnotify的功能,不过还是有一些差别的。

  1. park不需要获取某个对象的锁
  2. 因为中断的时候park不会抛出InterruptedException异常,所以需要在park之后自行判断中断状态,然后做额外的处理

我们在park线程的时候可以传递一些信息,给调用者看,这个object什么都能传递。

比如在阻塞时:

LockSupport.park("我被阻塞了");

主线程可以在t1的阻塞期间获取它传入的信息:

t1.start();
Thread.sleep(1000L);
System.out.println(LockSupport.getBlocker(t1));
t2.start();

七、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 {
    //如果不能获取锁,则直接做其他事情
}

7.1、可重入锁ReentrantLock

public class Ticket implements Runnable{
    private static final ReentrantLock lock = new ReentrantLock();
    private static Integer out = 100;

    String name;

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

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

    public static void main(String[] args) {
        Thread one = new Thread(new Ticket("一号窗口"));
        Thread two = new Thread(new Ticket("二号窗口"));
        one.start();
        two.start();
        ThreadUtils.sleep(10000);
    }
}

synchronized和ReentrantLock的区别:

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

7.2、读写锁ReadWriteLock

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

本质上就是这个工具类提供了两种锁,读锁和写锁,读的时候可以多线程的读,写的时候只能一个线程去写,保证线程安全

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

    public static void main(String[] args) {
        Runnable read = () -> {
            lock.readLock().lock();
            try {
                ThreadUtils.sleep(200);
                System.out.println("i am reading:" + COUNT);
            } finally {
                lock.readLock().unlock();
            }
        };
        Runnable write = () -> {
            lock.writeLock().lock();
            try {
                ThreadUtils.sleep(200);
                System.out.println("i an writing:" + ++COUNT);
            } finally {
                lock.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();
            }
        }
    }
}

八、CAS && AQS

8.1、CAS(Compare and Set)

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

缺点:

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

8.2、AQS

AQS中维护了一个volatile int state(共享资源)和一个CLH队列。当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败,失败的线程被放入一个FIFO的等待队列中,然后会被**UNSAFE.park()**操作挂起,等待已经获得锁的线程释放锁才能被唤醒。