多线程初级(中)

上一篇介绍了什么是线程以及创建多线程的两种常用方式(Callable放到下次说),这篇来简单聊聊多线程的“锁”。

其实,很多初学者(包括我自己)初期学习多线程时都被视频带偏了...虽然我始终认为培训班的视频是最适合非科班零基础入门的,但是在多线程方面,无一例外都讲得比较糟糕。

感触很深的一点是:很多新手觉得多线程难,并不是因为volatile、ReentrantLock或者Executor线程池,而是从一开始就没弄明白“什么是锁”,导致后面根本学不进去。

  • 什么是“锁”?
  • 锁到底长啥样?
  • 它锁定的是代码吗?

在我看来,这个问题不搞清楚,后面的内容根本学不明白。而一旦搞清楚这些概念,后面很多问题其实也就迎刃而解。

内容介绍:

  • 线程安全问题与解决办法
  • 锁到底长啥样
  • 关于锁的几个案例
  • 面试题:写一个固定容量的同步容器

线程安全问题与解决办法

在上一篇结尾,我们说Java两种创建多线程的方法中,一般推荐实现Runnable接口的方式。主要原因可以归结为:

  • 资源和线程分离,更加面向对象
  • 可以做到资源共享

而所谓的线程安全问题可以粗浅地理解为“数据不一致”。但单纯的资源共享并不一定会导致线程安全问题。当同时满足以下三个条件时,才可能引发线程安全问题。

  • 多线程环境
  • 有共享数据
  • 有多条语句操作共享数据/单条语句本身非原子操作

来看一段 @养兔子的大叔(JDK)ReetrantLock手撕AQS一文中关于线程安全的示例代码:

public class ThreadForIncrease {
    static int cnt = 0;  //共享数据cnt
    public static void main(String[] args) {
         Runnable r = new Runnable() {
            @Override
            public void run() {
                //有多条语句操作共享数据
                int n = 10000;
                while(n>0){
                    cnt++;
                    n--;
                }
            }
        };
        //多线程环境
        Thread t1  = new Thread(r);
        Thread t2  = new Thread(r);
        Thread t3  = new Thread(r);
        Thread t4  = new Thread(r);
        Thread t5  = new Thread(r);
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();

        try {
            //等待足够长的时间 确保上述线程均执行完毕
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(cnt);
    }

}

//输出的结果会小于50000

很明显,上面示例完全符合“线程安全问题”的三个条件。

出现问题的原因在于cnt++并不是原子性操作,实际上分三步:

  1. 各个线程从主存拷贝变量
  2. 在自己的工作内存进行+1操作
  3. 把结果回写到主存
5个线程总共执行50000次,如果发生多次上面的情况,比如99重复回写,200重复回写,那最终结果就是49998

如何解决?仔细回想一下三个条件:

  • 多线程环境(这个是前提,无法改变,没有多线程当然没有安全问题)
  • 有共享数据(通常无法改变,特定情境下必须要操作共享数据)
  • 非原子性操作(可以改变!)

所以经过分析,我们能优化的只有第三点:把对共享数据的操作变成原子性操作。针对上面的情况解决办法有多种,比如cnt使用原子类AutomicInteger,或者加锁等等。这里演示加锁的情况(其实这种情况加锁有点下药过猛了)。

//使用synchronized实现多线程累加操作
public class synchronizedForIncrease {
    static int cnt = 0;
    public static void main(String[] args) {
         Runnable r = new Runnable() {
            @Override
            public synchronized void run() {//同步方法(synchronized加锁)
                int n = 10000;
                while(n>0){
                    cnt++;
                    n--;
                }
            }
        };
        Thread t1  = new Thread(r);
        Thread t2  = new Thread(r);
        Thread t3  = new Thread(r);
        Thread t4  = new Thread(r);
        Thread t5  = new Thread(r);
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();

        try {
            //等待足够长的时间 确保上述线程均执行完毕
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(cnt);
    }

}
//输出结果将和预想中的一致:50000

用synchronized修饰run()方法后,就相当于将方法内的多个语句捆绑在一起,要么全部执行,要么尚未开始,不会出现“执行到一半被挂起”的情况,也就避免了线程安全问题的发生。


锁到底长啥样

其实“锁”本身是个对象,且理论上可以是任意对象。synchronized这个关键字不是“锁”,硬要说的话,加synchronized仅仅是相当于“加锁”这个操作,真正的锁是“某一个对象”。

所以,所谓的加锁,严格意义上不是锁住代码块!如果这样想的话,后面很多问题就没法解释了。

补充几个概念:

  • 互斥的最基本条件是:共用同一把锁
  • 静态方法的锁是所在类的字节码对象:xxx.class对象,普通方法的锁是this对象
  • 针对同一个线程,synchronized锁是可重入的

下面通过几个小案例,帮大家加深对上面三句话的理解


关于锁的几个案例

  • 同一个类中的synchronized method m1和method m2互斥吗?
t1线程执行m1方法时要去读this对象锁,但是t2线程并不需要读锁,两者各管各的,没有交集(不共用一把锁)


  • 同一个类中synchronized method m1中可以调用synchronized method m2吗?
synchronized是可重入锁,可以粗浅地理解为同一个线程在已经持有该锁的情况下,可以再次获取锁,并且会在某个状态量上做+1操作


  • 子类同步方法synchronized method m可以调用父类的synchronized method m吗(super.m())?
子类对象初始化前,会调用父类构造方法,在结构上相当于包裹了一个父类对象,用的都是this锁对象


  • 静态同步方法和非静态同步方法互斥吗?
各玩各的,不是同一把锁,谈不上互斥

面试题:写一个固定容量的同步容器

据说是淘宝?很久以前的一道面试题:

面试题:写一个固定容量的同步容器,拥有put和get方法,以及getCount方法,能够支持2个生产者线程以及10个消费者线程的阻塞调用

wait/notifyAll实现:

public class MyContainer1<T> {
	final private LinkedList<T> lists = new LinkedList<>();
	final private int MAX = 10; //固定容量,假定最多10个元素
	private int count = 0;
	
	//put方法
	public synchronized void put(T t) {
		while(lists.size() == MAX) { //想想为什么用while而不是用if?
			try {
				this.wait();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		
		lists.add(t);
		++count;
		this.notifyAll(); //通知消费者线程进行消费
	}
	
        //get方法
	public synchronized T get() {
		T t = null;
		while(lists.size() == 0) {
			try {
				this.wait();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		t = lists.removeFirst();
		count --;
		this.notifyAll(); //通知生产者进行生产
		return t;
	}
	
	public static void main(String[] args) {
		MyContainer1<String> c = new MyContainer1<>();
		//启动消费者线程
		for(int i=0; i<10; i++) {
			new Thread(()->{
				for(int j=0; j<5; j++) 
                                  System.out.println(c.get());
			}, "c" + i).start();
		}
		
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		//启动生产者线程
		for(int i=0; i<2; i++) {
			new Thread(()->{
				for(int j=0; j<25; j++) 
                                  c.put(Thread.currentThread().getName() + " " + j);
			}, "p" + i).start();
		}
	}
}

对于初学者,这个面试题的难点在于:


首先,能想到在MyContainer中塞入LinkedList作为容器(因为有removeFirst方法,比较方便)。Java集合体系中,已经提供了足够多的容器,我们如果要模拟自己的容器,一般会选择将现有的容器包装进自己的容器中,而不是去自己实现一个容器。


其次,wait方法必须配合notifyAll。据说《Effective Java》甚至提出,wait在绝大多数场景下应该伴随着notifyAll而不是notify。因为notify的唤醒是随机,不能确定唤醒的是哪个线程(可能是消费者方,也可能是生产者方)。所以当某个生产者线程生产完第10个商品让出执行权后,下次抢到执行权的可能还是生产者方的其他线程(触发lists.size()==MAX条件),这样全部生产者线程就会等待(在此之前消费者线程也已经全部等待),整个程序就会发生死锁:

第⑤步只是举个例子,实际上也有可能是唤醒消费者,因为notify的唤醒是随机的

如果还是有同学不明白为什么生产者线程最终会全部等待,可以看看下面的例子,虽然不够贴切,但是以我的美术功底,尽力了:

如果是notifyAll,则会唤醒所有线程,且各个线程抢到执行权的概率是一致的。即使下一次还是生产者线程抢到执行权并且等待了,此时还有其他线程是活着的。

最后,由于理论上锁可以是任意对象,所以锁的wait/notify/notifyAll等方法就被定义在Object类中,让所有类去继承。如果你仍觉得synchronized才是锁,这个问题是解释不通的。所以,请明确,wait/notify/notifyAll这些方法都是锁对象的方法,线程之所以会产生等待、唤醒等一系列状态,都是去读取锁对象时被指定的。

wait
notify
notifyAll


最后,提供ReentrantLock实现的版本,更为简单,而且可以精确唤醒生产者线程/消费者线程:

public class MyContainer2<T> {
	final private LinkedList<T> lists = new LinkedList<>();
	final private int MAX = 10; //最多10个元素
	private int count = 0;
	
	private Lock lock = new ReentrantLock();
	private Condition producer = lock.newCondition();
	private Condition consumer = lock.newCondition();
	
	public void put(T t) {
		try {
			lock.lock();
			while(lists.size() == MAX) { //想想为什么用while而不是用if?
				producer.await();
			}
			
			lists.add(t);
			++count;
			consumer.signalAll(); //通知消费者线程进行消费
		} catch (InterruptedException e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}
	
	public T get() {
		T t = null;
		try {
			lock.lock();
			while(lists.size() == 0) {
				consumer.await();
			}
			t = lists.removeFirst();
			count --;
			producer.signalAll(); //通知生产者进行生产
		} catch (InterruptedException e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
		return t;
	}
	
	public static void main(String[] args) {
		MyContainer2<String> c = new MyContainer2<>();
		//启动消费者线程
		for(int i=0; i<10; i++) {
			new Thread(()->{
				for(int j=0; j<5; j++) System.out.println(c.get());
			}, "c" + i).start();
		}
		
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		//启动生产者线程
		for(int i=0; i<2; i++) {
			new Thread(()->{
				for(int j=0; j<25; j++) c.put(Thread.currentThread().getName() + " " + j);
			}, "p" + i).start();
		}
	}
}


按照惯例,还是留一道思考题,是我之前面试被考到的,咋一听有点懵,其实本质是一样的。始终抓住锁的本质即可迎刃而解:

一个对象的get/set方法如果加上synchronized,t1访问get方法,t2访问set方法,这两个线程互斥吗?

2019-2-23 16:00:00


下期预告:

简单介绍一下多线程相关的其他内容,比如volatile、ReentrantLock、ThreadLocal。写完基本上就算和大叔写的多线程内容衔接上了:

养兔子的大叔:(JDK)Volatile解析

养兔子的大叔:(JDK)ReetrantLock手撕AQS

养兔子的大叔:Java线程池


博主耗费一年时间编写的Java进阶小册已经上线,覆盖日常开发所需大部分技术,且通俗易懂、深入浅出、图文丰富,需要的同学请戳:

2021-07-06

编辑于 2021-07-06 08:32

文章被以下专栏收录