一、synchronized
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对synchronized 进行了各种优化之后,有些情况下它就并不那么重。
synchronized 有三种方式来加锁,分别是
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
分类 | 具体分类 | 被锁对象 | 伪代码 |
方法 | 实例方法 | 调用该方法的实例对象 | 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
- Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
- Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
- 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);
这儿park
和unpark
其实实现了wait
和notify
的功能,不过还是有一些差别的。
-
park
不需要获取某个对象的锁 - 因为中断的时候
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)
它的思路其实很简单,就是给一个元素赋值的时候,先看看内存里的那个值到底变没变,如果没变我就修改,变了我就不改了,其实这是一种无锁操作,不需要挂起线程,无锁的思路就是先尝试,如果失败了,进行补偿,也就是你可以继续尝试。这样在少量竞争的情况下能很大程度提升性能。
缺点:
- ABA问题。当第一个线程执行CAS操作,尚未修改为新值之前,内存中的值已经被其他线程连续修改了两次,使得变量值经历 A -> B -> A的过程。绝大部分场景我们对ABA不敏感。解决方案:添加版本号作为标识,每次修改变量值时,对应增加版本号; 做CAS操作前需要校验版本号。JDK1.5之后,新增AtomicStampedReference类来处理这种情况。
- 循环时间长开销大。如果有很多个线程并发,CAS自旋可能会长时间不成功,会增大CPU的执行开销。
- 只能对一个变量进行原子操作。JDK1.5之后,新增AtomicReference类来处理这种情况,可以将多个变量放到一个对象中。
8.2、AQS
AQS中维护了一个volatile int state(共享资源)和一个CLH队列。当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败,失败的线程被放入一个FIFO的等待队列中,然后会被**UNSAFE.park()**操作挂起,等待已经获得锁的线程释放锁才能被唤醒。