多线程的“锁”
其实,很多初学者(包括我自己)初期学习多线程时都被视频带偏了...虽然我始终认为培训班的视频是最适合非科班零基础入门的,但是在多线程方面,无一例外都讲得比较糟糕。
感触很深的一点是:很多新手觉得多线程难,并不是因为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操作
- 把结果回写到主存
如何解决?仔细回想一下三个条件:
- 多线程环境(这个是前提,无法改变,没有多线程当然没有安全问题)
- 有共享数据(通常无法改变,特定情境下必须要操作共享数据)
- 非原子性操作(可以改变!)
所以经过分析,我们能优化的只有第三点:把对共享数据的操作变成原子性操作。针对上面的情况解决办法有多种,比如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互斥吗?
- 同一个类中synchronized method m1中可以调用synchronized method m2吗?
- 子类同步方法synchronized method m可以调用父类的synchronized method m吗(super.m())?
- 静态同步方法和非静态同步方法互斥吗?
面试题:写一个固定容量的同步容器
据说是淘宝?很久以前的一道面试题:
面试题:写一个固定容量的同步容器,拥有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();
}
//启动生产者线程