并发编程四、死锁及线程隔离

一、死锁

1. 死锁、活锁

  死锁:一组互相竞争资源的线程因互相等待,导致'永久'阻塞的现象。
  活锁:活锁指的是任务或执行者没有被阻塞,由于某些条件没有满足导致一直重复尝试-失败-尝试-失败的过程。处于活锁的实体是在不断改变状态,有可能被解开。可以看做一个线程需要N把锁,当获取到第一把锁,但是后续锁获取不成功,不是直接阻塞当前线程而是释放第一把锁再重新尝试。

  一个例子简单模拟死锁
创建两把锁lock1、lock2,两个线程t1、t2。其中t1线程持有lock1抢占lock2、t2线程持有lock2抢占lock1,这样的话两个线程都持有对方需要的锁的同时又需要对方持有的锁,导致互相阻塞且不可能取得对方的锁而阻塞。

public class DeadLockSimple {

    private static Object lock1 = new Object();
    private static Object lock2 = new Object();

    public static void main(String[] args) {

        Thread t1 = new Thread(()-> {

            synchronized (lock1) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lock2) {

                    System.out.println(Thread.currentThread().getName() + " 获得锁" + lock1 + "," + lock2);

                }

            }

        }, "t1");

        Thread t2 = new Thread(()-> {

            synchronized (lock2) {

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lock1) {

                    System.out.println(Thread.currentThread().getName() + " 获得锁" + lock1 + "," + lock2);

                }

            }

        }, "t2");

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

    }

}

此时可以使用JVM查看堆栈的工具jstack来定位:
  首先命令行jps查看程序pid,如下 DeadLockSimple 对应pid为 16412

C:\Users\...>jps
1072 Launcher
11136 RemoteMavenServer
16704 Launcher
17236 Jps
9764 Launcher
14588 Bootstrap
16412 DeadLockSimple
5980

  之后查看对应堆栈及线程信息,如下可以看到程序存在一个死锁:具体线程名、类、代码行号也都可以定位。

C:\Users\...>jstack 16412
...
...

Found one Java-level deadlock:
=============================
"t2":
  waiting to lock monitor 0x000000001a3e35f8 (object 0x000000078018e7f0, a java.lang.Object),
  which is held by "t1"
"t1":
  waiting to lock monitor 0x000000001a3e0d68 (object 0x000000078018e800, a java.lang.Object),
  which is held by "t2"

Java stack information for the threads listed above:
===================================================
"t2":
        at org.example.day4.deadlock.DeadLockSimple.lambda$main$1(DeadLockSimple.java:42)
        - waiting to lock <0x000000078018e7f0> (a java.lang.Object)
        - locked <0x000000078018e800> (a java.lang.Object)
        at org.example.day4.deadlock.DeadLockSimple$$Lambda$2/1831932724.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:745)
"t1":
        at org.example.day4.deadlock.DeadLockSimple.lambda$main$0(DeadLockSimple.java:22)
        - waiting to lock <0x000000078018e800> (a java.lang.Object)
        - locked <0x000000078018e7f0> (a java.lang.Object)
        at org.example.day4.deadlock.DeadLockSimple$$Lambda$1/990368553.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:745)

Found 1 deadlock.

2. 死锁发生的条件

当以下四个条件同时满足时,则会发送死锁:
  1. 互斥,共享资源X和Y只能被一个线程所占用;
  2. 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  3. 不可抢占,其它线程不能强行抢占线程T1占有的资源;
  4. 循环等待,线程T1等待线程T2释放资源,而线程T2也等待线程T1释放资源,就是循环等待。

3. 如何解决死锁

  按照之前所说,以上四条必须同时满足才会发生死锁,那我们只需破坏其中一个条件,就可以解开死锁了。
其中第一条 互斥 是不能被破坏的,因为加锁本身就是互斥的,以次来保证线程安全。其它三个条件是可以破坏的:

对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

这三个条件,我会以一个例子来演示分别将其破坏。

  我们以两个账户互相转账为原型,演示可能会出现死锁的场景:
首选,创建账户Account,内有:账户名、余额属性及转入、转出方法。

public class Account {

    //账户名
    private String accountName;

    //余额
    private int balance;

    public Account(String accountName, int balance) {
        this.accountName = accountName;
        this.balance = balance;
    }

    // 转出
    public void out(int amount) {
        this.balance -= amount;
    }

    // 转入
    public void in(int amount) {
        this.balance += amount;
    }

    public int getBalance() {
        return balance;
    }

    public String getAccountName() {
        return accountName;
    }
}

之后,创建两个账户A、B,两个线程同时使用这两个账户,不过一个线程进行A转入B,一个线程进行B转入A。

public class TransferAccount implements Runnable{

    private Account fromAccount;

    private Account toAccount;

    private int amount;

    public TransferAccount(Account fromAccount, Account toAccount, int amount) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
    }


    @Override
    public void run() {

        while (true) {
            synchronized (fromAccount) {
                synchronized (toAccount) {

                    if (fromAccount.getBalance() >= amount) {
                        // 转出
                        fromAccount.out(amount);
                        // 转入
                        toAccount.in(amount);
                    }
                    System.out.println(Thread.currentThread().getName() + " FromAccount " + fromAccount.getAccountName() + " -> " + fromAccount.getBalance());
                    System.out.println(Thread.currentThread().getName() + " ToAccount " + toAccount.getAccountName() + " -> " + toAccount.getBalance());

                }
            }
        }

    }

    public static void main(String[] args) {

        Account accountA = new Account("lei", 50000);
        Account accountB = new Account("li", 20000);

        // A账户转B账户50
        Thread t1 = new Thread(new TransferAccount(accountA, accountB, 50), "t1");
        // B账户转A账户10
        Thread t2 = new Thread(new TransferAccount(accountB, accountA, 10), "t2");

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

    }

}

运行该demo,等待一段时间之后可能会出现控制台不再打印转账信息,但程序并没有退出。此时使用jstack来查看当前进程线程信息,会发现程序已经出现死锁。
这是因为两个线程在启动后,不停自旋进行转账操作,转账时会将两个账户进行加锁,但是其中一个线程是先获取A账户资源进行加锁、然后获取B账户资源;另一个线程是先获取B账户资源进行加锁、之后获取A账户资源进行加锁;在不停自旋重试中,总会出现t1线程获取到A账户资源进行加锁的同时t2线程获取到B账户资源进行加锁,这样的话就会满足互斥、占有且等待、不可抢占、循环等待四个条件,发送死锁。

破坏条件二:占有且等待

破坏条件二、占有且等待来破坏死锁:实现逻辑就是创建一个总线锁,一次性申请所有的资源,所以这个锁包含所有的子锁资源,如果总线锁能拿到,表示针对当前线程一系列自锁的资源都可用。

import java.util.ArrayList;
import java.util.List;

public class MainLock {

    List<Object> list = new ArrayList<>();

    /**
     * 获取所有资源
     * @param from
     * @param to
     * @return
     */
    public synchronized Boolean tryLock(Account from, Account to) {
        if (list.contains(from) || list.contains(to)) {
            return false;
        }
        list.add(from);
        list.add(to);
        return true;
    }

    /**
     * 释放资源
     * @param from
     * @param to
     */
    public synchronized void release(Account from, Account to) {
        list.remove(from);
        list.remove(to);
    }

}

转账操作的改造:

public class TransferAccount2 implements Runnable{

    private Account fromAccount;

    private Account toAccount;

    private int amount;

    private MainLock mainLock;

    public TransferAccount2(Account fromAccount, Account toAccount, int amount, MainLock mainLock) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
        this.mainLock = mainLock;
    }

    @Override
    public void run() {

        while (true) {

            if (mainLock.tryLock(fromAccount, toAccount)) {
                synchronized (fromAccount) {
                    synchronized (toAccount) {
                        if (fromAccount.getBalance() >= amount) {
                            // 转出
                            fromAccount.out(amount);
                            // 转入
                            toAccount.in(amount);
                        } else {
                            System.out.println(Thread.currentThread().getName() + " FromAccount 转账余额不足,要转出: " + amount + ";目前有: " + fromAccount.getBalance());
                        }
                    }
                }
                mainLock.release(fromAccount, toAccount);
            }


            System.out.println(Thread.currentThread().getName() + " FromAccount " + fromAccount.getAccountName() + " -> " + fromAccount.getBalance());
            System.out.println(Thread.currentThread().getName() + " ToAccount " + toAccount.getAccountName() + " -> " + toAccount.getBalance());

        }

    }

    public static void main(String[] args) {

        // 加锁方式为对象锁,所有必须提前创建好一个总锁对象,两个线程同时持有mainLock对象
        MainLock mainLock = new MainLock();

        Account account1 = new Account("lei", 50000);
        Account account2 = new Account("li", 20000);


        // 1账户转2账户50
        Thread t1 = new Thread(new TransferAccount2(account1, account2, 50, mainLock), "t1");
        // 2账户转1账户10
        Thread t2 = new Thread(new TransferAccount2(account2, account1, 10, mainLock), "t2");

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

    }

}

此时因为多个线程首先竞争的只有一把锁mainLock,竞争到才能进行后续操作,不会存在占有且等待的情况。运行也不会出现死锁。

破坏条件三:不可抢占

  破坏条件三、不可抢占来破坏死锁:实现逻辑就是如果线程持有锁资源后再去获取别的锁资源但没有获取到,主动释放它占有的资源,这样就不存在不可抢占了。
使用java.util.concurrent.locks.ReentrantLock#tryLock()来实现,查看源码实现可以看到,如果当前线程不能获取到锁资源,并不会将当前线程阻塞而是直接返回false,所有先现有逻辑判断是false继续后续代码逻辑,当逻辑执行完成自然就将锁释放了。
一般的实际应用场景也推荐这种操作。
转账代码改造:

public class TransferAccount3 implements Runnable{

    private Account fromAccount;

    private Account toAccount;

    private int amount;

    private Lock fromLock = new ReentrantLock();
    private Lock toLock = new ReentrantLock();

    public TransferAccount3(Account fromAccount, Account toAccount, int amount) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
    }

    @Override
    public void run() {

        while (true) {
            // tryLock方法在不能获取到锁时,并不会像synchronized一样将当前线程阻塞,而是直接返回false,当前线程逻辑判断为false,继续后面逻辑并释放锁, 然后继续下次while循环
            if (fromLock.tryLock()) {
                if (toLock.tryLock()) {
                    if (fromAccount.getBalance() >= amount) {
                        // 转出
                        fromAccount.out(amount);
                        // 转入
                        toAccount.in(amount);
                    } else {
                        System.out.println(Thread.currentThread().getName() + " FromAccount 转账余额不足,要转出: " + amount + ";目前有: " + fromAccount.getBalance());
                        Thread.currentThread().interrupt();
                    }
                }
            }

            System.out.println(Thread.currentThread().getName() + " FromAccount " + fromAccount.getAccountName() + " -> " + fromAccount.getBalance());
            System.out.println(Thread.currentThread().getName() + " ToAccount " + toAccount.getAccountName() + " -> " + toAccount.getBalance());

        }

    }

    public static void main(String[] args) {

        Account account1 = new Account("lei", 50000);
        Account account2 = new Account("li", 20000);

        // 1账户转2账户50
        Thread t1 = new Thread(new TransferAccount3(account1, account2, 50), "t1");
        // 2账户转1账户10
        Thread t2 = new Thread(new TransferAccount3(account2, account1, 10), "t2");

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

    }

}

破坏条件四:循环等待

  破坏条件四、循环等待来破坏死锁:可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序0的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
实现逻辑就是将锁资源排序,这样多个线程来竞争锁竞争到第一把锁,后面的自然能获取到;竞争不到第一把锁,后面自然不能竞争。
转账代码改造:

public class TransferAccount4 implements Runnable{

    private Account fromAccount;

    private Account toAccount;

    private int amount;

    public TransferAccount4(Account fromAccount, Account toAccount, int amount) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
    }


    @Override
    public void run() {

        // left为大
        Account left = fromAccount.hashCode() > toAccount.hashCode() ? fromAccount : toAccount;
        Account right = fromAccount.hashCode() > toAccount.hashCode() ? toAccount : fromAccount;

        while (true) {

            // 锁定加锁的顺序,先从hashCode比较大的加锁
            synchronized (left) {
                synchronized (right) {
                    if (fromAccount.getBalance() >= amount) {
                        // 转出
                        fromAccount.out(amount);
                        // 转入
                        toAccount.in(amount);
                    }
                }
            }

            System.out.println(Thread.currentThread().getName() + " FromAccount " + fromAccount.getAccountName() + " -> " + fromAccount.getBalance());
            System.out.println(Thread.currentThread().getName() + " ToAccount " + toAccount.getAccountName() + " -> " + toAccount.getBalance());

        }

    }

    public static void main(String[] args) {

        Account account1 = new Account("lei", 50000);
        Account account2 = new Account("li", 20000);

        // 1账户转2账户50
        Thread t1 = new Thread(new TransferAccount4(account1, account2, 50));
        // 2账户转1账户10
        Thread t2 = new Thread(new TransferAccount4(account2, account1, 10));

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

    }

}

此时执行也就不再会出现死锁。

二、线程隔离机制 ThreadLocal

ThreadLocal的使用

  先看一下下面这个例子,五个线程内部同时对静态变量num进行+5操作,最终结果是什么?

public class ThreadLocalDemo {

    static int num = 0;

    public static void main(String[] args) {

        Thread[] array = new Thread[5];

        for (int i = 0; i < 5; i ++ ) {
            array[i] = new Thread(()-> {
                num += 5;
                System.out.println(Thread.currentThread().getName() + " -- " + num);
            }, "t" + i);
        }

        for (int i = 0 ; i < 5; i ++) {
            array[i].start();
        }

    }

}

某一次的运行结果:

t0 -- 10
t2 -- 15
t1 -- 10
t3 -- 15
t4 -- 25

其实最终输出结果是不固定的,因为num是静态变量,每个线程都可以访问到,再结合CPU高速缓存、指令重排序、读取与写入主内存的先后顺序、时间片切换线程的执行顺序..不可控制的因素有很多。
  那如果我想要每个线程内数据隔离,每个线程内都只能读取、写入自己当前线程的内容,应该怎么做?
Java中提供了ThreadLocal关键字来保证线程隔离

public class ThreadLocalDemo2 {

    static ThreadLocal<Integer> local = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };

    public static void main(String[] args) {

        Thread[] array = new Thread[5];

        for (int i = 0; i < 5; i ++ ) {
            array[i] = new Thread(()-> {
                Integer num = local.get();
                num += 5;
                local.set(num);
                System.out.println(Thread.currentThread().getName() + " -- " + local.get());
            }, "t" + i);
        }

        for (int i = 0 ; i < 5; i ++) {
            array[i].start();
        }

    }

}

...运行结果
t0 -- 5
t3 -- 5
t4 -- 5
t1 -- 5
t2 -- 5

  需要注意的是:赋初始值的java.lang.ThreadLocal#initialValue方法,如果为对象的话,必须每个线程独占一个对象,不能是公用的一个对象,因为如果是一个公共的对象,多个线程其实访问的仍是同一块内存地址,修改的结果仍会互相影响,不能实现线程隔离。如下:

public class ThreadLocalDemo3 {

    static Person person = new Person();

    static ThreadLocal<Person> local = new ThreadLocal<Person>(){
        @Override
        protected Person initialValue() {
            // 初始值对象不能多线程公用
            return person;
            // 每次初始化单独创建对象
//            return new Person();
        }
    };

    public static void main(String[] args) {

        Thread[] array = new Thread[6];

        for (int i = 0; i < array.length; i++) {

            array[i] = new Thread(()-> {

                Person person = local.get();
                person.age = new Random().nextInt(100);
                System.out.println(Thread.currentThread().getName() + " ---- " + person.age);
                local.set(person);
                System.out.println(Thread.currentThread().getName() + " ---- " + local.get());
            });

        }

        for (int i = 0; i < array.length; i++) {
            array[i].start();
        }

    }

}

class Person {

    public int age;

    @Override
    public String toString() {
        return "Person{" +
                "age=" + age +
                '}';
    }
}

...运行结果 
Thread-1 ---- 93
Thread-5 ---- 65
Thread-3 ---- 86
Thread-5 ---- Person{age=86}
Thread-1 ---- Person{age=86}
Thread-0 ---- 69
Thread-0 ---- Person{age=69}
Thread-3 ---- Person{age=69}
Thread-2 ---- 96
Thread-2 ---- Person{age=96}
Thread-4 ---- 35
Thread-4 ---- Person{age=35}

可以看到,虽然使用了ThreadLocal,但多个线程公用的是同一个person对象,多个线程之间的结果会相互影响(Thread-1为93,但Thread-1的person对象内age为86),不能达到线程隔离的目的。

ThreadLocal的原理分析

  先猜测,后验证:
因为ThreadLocal是实现线程隔离的,所以ThreadLocal应该是每个线程独有的,所以应该是Thread的一个属性;
因为我们在定义类时可以创建1到多个ThreadLocal变量,所以这个属性不能简单是一个ThreadLocal对象,应该是一个集合;
从源码来入手的话,可以先看set()方法中传入的值具体是怎么存储的、之后get()方法应该是从同一块内存中取出的;

  1. 当第一次set,ThreadLocalMap为空时:
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else // 1. 第一次set,map为空,初始化Map
            createMap(t, value);
    }

...	

    void createMap(Thread t, T firstValue) {
	// 2. 构造方法创建ThreadLocalMap,传入 this[ThreadLocal对象],firstValue[对应具体值] 
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

...

	ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
		// 3. 创建一个默认长度的Entry数组  默认=16
		table = new Entry[INITIAL_CAPACITY];
		// 4. 计算数组存放位置下标,根据key的散列哈希计算 -> TODO 后续可扩展  private static final int HASH_INCREMENT = 0x61c88647;
		int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
		// 5. 封装entry对象,存储我们传入的值于数组当中,其中key为threadLocal,value为我们设置的具体值
		table[i] = new Entry(firstKey, firstValue);
		size = 1;
		setThreshold(INITIAL_CAPACITY);
	}	
	
	// Entry为WeakReference,弱引用类型
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }	
			
  1. 后续set,map不为空时

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)//之后set,map已经创建
            map.set(this, value);
        else
            createMap(t, value);
    }

...

	private void set(ThreadLocal<?> key, Object value) {

		// We don't use a fast path as with get() because it is at
		// least as common to use set() to create new entries as
		// it is to replace existing ones, in which case, a fast
		// path would fail more often than not.

		Entry[] tab = table;
		int len = tab.length;
		// 计算数组下标
		int i = key.threadLocalHashCode & (len-1);

		// 从下标i开始往后一直遍历到数组最后一个Entry(线性探索)
		for (Entry e = tab[i];
			 e != null;
			 e = tab[i = nextIndex(i, len)]) {
			ThreadLocal<?> k = e.get();

			if (k == key) {
				// key相同,说明是同一个threadLocal,覆盖值value 并退出
				e.value = value;
				return;
			}
			
			
			if (k == null) {
				//如果key为null,说明弱引用已被回收
				//用新key、value覆盖,同时清理历史key=null的陈旧数据(弱引用) 并退出
				replaceStaleEntry(key, value, i);
				return;
			}
		}

		tab[i] = new Entry(key, value);
		int sz = ++size;
		//key不同,如果超过阀值,需要扩容
		if (!cleanSomeSlots(i, sz) && sz >= threshold)
			rehash();
	}

此时如果数组下标冲突,会使用开放地址法来解决哈希冲突,自动向后查找最近的空闲位置来存储。
开放寻址的ThreadLocalMap分析

  1. get逻辑
    public T get() {
        Thread t = Thread.currentThread();
	//1. 根据线程拿到ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
		// 5. entry对象对应的value即为我们set的值,返回
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

...

	private Entry getEntry(ThreadLocal<?> key) {
		// 2. 根据key拿到数组下标
		int i = key.threadLocalHashCode & (table.length - 1);
		// 3. 拿到entry
		Entry e = table[i];
		if (e != null && e.get() == key)
			// 4. entry不为空,取得
			return e;
		else
			return getEntryAfterMiss(key, i, e);
	}
	

三、并发基础总结

  1、什么是线程安全
原子性、有序性、可见性(CPU高速缓存、指令重排序、JMM内存模型)

  2、sleep、join()、yield() 的区别
sleep(long millis)会使线程进入TIMED_WAITING状态睡眠一定时间,会释放CPU资源,在时间结束后会自动置为可运行状态,分配到时间片就可以继续运行;
join()底层实现基于wait(),nofity(),让线程的执行结果可见。当A线程内使用threadB.join(),表示B线程内的全部操作对A线程的join()后续逻辑可见;
yield()让出时间片,触发重新调整。

  3、Java中能够创建volatile数组吗?
可以;不过volatile修饰的数组不能保证数组内元素的可见性

  4、Java中的++操作是线程安全的吗?
不是,线程安全要具备原子性、可见性、有序性;++操作不满足原子性;

  5、线程什么时候会抛出InterruptedException()
当线程thread.interrupt()去中断一个处于sleep()、join()、wait()阻塞状态的线程时,会抛出InterruptedException异常。

  6、Java 中Runnable和Callable有什么区别
Runnable无返回值,
Callable有返回值,配合ExecutorService,Future可以取得线程的返回值

  7、有T1/T2/T3三个线程,如何确保他们的执行顺序
使用join()

  8、Java内存模型是什么?
JMM是一个抽象的内存模型。
它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。
通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性、有序性。

posted @ 2019-07-09 21:47  BigShen  阅读(266)  评论(0编辑  收藏  举报