文章目录
1、Synchronized
在 Java 中,每个对象有且仅有一把锁(lock),也称为监视器(monitor)。
synchronized 是 Java 中的关键字,是一种同步锁。它修饰的目标有以下几种:
1、修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象(锁)是调用这个代码块的对象;
2、修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
3、修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
4、修改一个类,其作用的范围是 synchronized 后面括号括起来的部分,作用的对象是这个类的所有对象。
在并发编程中存在线程安全问题,主要原因有:
1.存在共享数据
2.多线程共同操作共享数据。
关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。
2、Synchronized的使用
使用并发编程的原则是:线程操作资源类。
2.1 修饰代码块
public class Demo01 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> {
for (int i = 0; i < 40; i++) ticket.sale();
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 40; i++) ticket.sale();
}, "B").start();
new Thread(() -> {
for (int i = 0; i < 40; i++) ticket.sale();
}, "C").start();
}
}
class Ticket {
private int number = 30;
public void sale() {
synchronized (this) {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出 "+this.toString()+" 的第 " + (30 - (number--) + 1) + " 张票,还剩余 " + number + " 张票。");
}
}
}
}
运行结果:
分析:资源类为Ticket
,三个线程分别是 A、B、C,this
就是ticket
对象。
1、三个线程启动后,三个线程都可以操作ticket
对象;
2、当三个线程都进入了sale()
方法,遇到了同步代码块,此时,哪个线程手快先拿到ticket
对象的锁,谁就进入,其他两个就在此等待,因为一个对象只有一个锁;
3、当执行完同步代码块,就把锁释放了,即此时ticket
对象又有锁了。
结果就是,同一时间,只有拿到锁的线程才能访问同步代码块,其他没拿到锁的只能处于阻塞状态等待。这是只有一个对象的情况,如果有ticket2
对象,那么,操作这个ticket2
对象的线程和操作ticket
对象的线程没有什么关系。
2.2 修饰非静态方法
public class Demo02 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> {
for (int i = 0; i < 40; i++) ticket.sale();
}, "A").start();
new Thread(() -> {
Ticket ticketB = new Ticket();
for (int i = 0; i < 40; i++) ticket.sale();
}, "B").start();
new Thread(() -> {
Ticket ticketC = new Ticket();
for (int i = 0; i < 40; i++) ticket.sale();
}, "C").start();
}
}
class Ticket {
private int number = 30;
public synchronized void sale() {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出 "+this.toString()+" 的第 " + (30 - (number--) + 1) + " 张票,还剩余 " + number + " 张票。");
}
}
}
运行结果:
分析:和修饰代码块的分析一样,此时的锁也是从this
对象获取。
2.3 修饰静态方法
public class Demo03 {
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 40; i++) new Ticket().sale();
}, "A").start();
new Thread(() -> {
Ticket ticketB = new Ticket();
for (int i = 0; i < 40; i++) new Ticket().sale();
}, "B").start();
new Thread(() -> {
Ticket ticketC = new Ticket();
for (int i = 0; i < 40; i++) new Ticket().sale();
}, "C").start();
}
}
class Ticket {
private static int number = 30;
public static synchronized void sale() {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第 " + (30 - (number--) + 1) + " 张票,还剩余 " + number + " 张票。");
}
}
}
运行结果:
分析:A、B、C 三个线程操作的是三个对象,根据结果可知,此时的锁已经不是Ticket
对象了,因为synchronized
修饰的是静态方法,此时的锁是Ticket
类的所有对象,即所有对象共用一把锁。
3、生产者消费者问题
3.1 一个生产者和一个消费者
所谓生产者消费者问题,指的是一个线程负责生产数据,一个线程负责消费数据。消费者消费的数据必须是来源于生产者生产的数据,要做到这一点就得进行线程同步操作。
下面举一个例子,一个资源类有一个number
变量,初始值为 0,资源类有两个方法,分别是 increment()
和 decrement()
分别对number
进行加一、减一操作。
下面是一个资源类:
class Data { // 数字 资源类
private int number = 0;
//+1
public synchronized void increment() throws InterruptedException {
if (number != 0) { //0
// 等待
this.wait();
}
number++;
System.out.println(Thread.currentThread().getName() + "=>" + number);
// 通知其他线程,我+1完毕了
this.notifyAll();
}
//-1
public synchronized void decrement() throws InterruptedException {
if (number == 0) { // 1
// 等待
this.wait();
}
number--;
System.out.println(Thread.currentThread().getName() + "=>" + number);
// 通知其他线程,我-1完毕了
this.notifyAll();
}
}
测试:
public class ProdAndCons {
public static void main(String[] args) {
Data data = new Data();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "B").start();
}
}
运行结果如下:
运行很正常。
1、生产者A判断出number
为0,则进行加一操作,然后执行notifyAll()
方法告知消费者B来消费。如果生产者A判断出number
不为0,说明消费者B还没消费,则生产者A进入 WAITING 状态,直到被唤醒。
2、消费者B判断出number
不为 0,则进行减一操作,然后执行notifyAll()
方法告知生产者A继续生产。如果消费者B判断出number
为0,说明生产者A还没生成,则消费者B进入 WAITING 状态,直到被唤醒。
3.2 多个生产者和多个消费者
现在多增加一个生产者和一个消费者:
public class ProdAndCons {
public static void main(String[] args) {
Data data = new Data();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "B").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "C").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "D").start();
}
}
运行效果:
可以发现一个诡异的现象:出现了非1非0的数字。
3.3 虚假唤醒
原因诊断:出现这些2、3、4的原因就是多个生产者线程进入increment()
方法,这些线程进入的时候,number
为0,然后这些线程都给number
进行了加一操作。
更加诡异:加一方法和减一方法都是用了 synchronized 关键字修饰了的,怎么可能出现多个线程同时进去的情况?
这个现象在 Java 官方称为“虚假唤醒”,究其原因就出现在wait()
方法上:
官方的意思说:只要把wait()
的触发判断改为循环,就可以避免,我们来尝试一下:
class Data { // 数字 资源类
private int number = 0;
//+1
public synchronized void increment() throws InterruptedException {
while (number != 0) { //0
// 等待
this.wait();
}
number++;
System.out.println(Thread.currentThread().getName() + "=>" + number);
// 通知其他线程,我+1完毕了
this.notifyAll();
}
//-1
public synchronized void decrement() throws InterruptedException {
while (number == 0) { // 1
// 等待
this.wait();
}
number--;
System.out.println(Thread.currentThread().getName() + "=>" + number);
// 通知其他线程,我-1完毕了
this.notifyAll();
}
}
把 if 改成了 while,运行结果如下:
还真的是正常了,这又是什么原理??
3.4 虚假唤醒的本质
问题就出在wait()
方法上,我们看这个方法的官方文档:
文档说的很晦涩,一句一句翻译一下:
当前的线程必须拥有该对象的显示器:显示器其实来源于单词(monitor),其实就是锁的意思,即:要调用wait()
方法,当前线程必须拥有调用wait()
方法的对象的锁;
该线程释放此监视器的所有权:调用了wait()
方法后,锁也给扔了;
并等待另一个线程通知等待该对象监视器的线程通过调用notify方法或notifyAll方法notifyAll:等着被其他线程唤醒。
看明白了这三句话,就知道虚假唤醒问题出在哪了,就在这关键的一句话:该线程释放此监视器的所有权。也就是说,锁扔了。那锁扔了会有什么后果呢?后果就是阻塞中的、等待获取这个锁的线程直接就拿到锁了,然后进入 synchronized 方法了。
要知道,上面的代码中,wait()
方法是在 synchronized 修饰的方法里面,还没出去。这就导致多个线程同时进入了 synchronized 修饰的方法里面。
我们引入 synchronized 来修饰方法,就是为了避免多个线程对变量的一个状态同时进行判断,比如:number 为 0,然后多个线程在同一时刻判断它,结果多个线程都认为该加一,导致的后果就是每个线程都给它加一,最终效果就不是加一了,而是执行了多次加一。
下面仔细分析出现虚假唤醒后的逻辑:
A、B 线程是生产者,对 number 进行加一操作。
1、A 线程进入increment()
方法,判断出 number 等于1,于是 this.wait()
,丢掉了锁。
2、线程 B 拿到了锁,进入了increment()
方法,此时 number 已经被消费了,为 0,线程 B 判断出 number 为 0,于是进行了加一操作,然后线程 B 离开 synchronized 方法,释放了锁并this.notifyAll()
通知所有等待中的线程;
3、线程 A 被唤醒,同时也拿到了锁,继续往下执行了加一操作,这时,number 就变为 2了。
我们分析出,只要线程 A 在恢复并拿到锁之后,再判断一次 number 的状态,即可避免再加一,这就是官方文档要我们使用循环的原因了。
线程 A 在丢掉锁之后,这个 synchronized 方法已经被其他的线程 B 进入了,为了避免出错,线程 A 应对当前状态再进行判断,这是最安全的操作。