在实现多线程时,很容易出现线程安全问题,一般是由于多个线程使用了共享资源造成的数据不一致导致的,先来看一波存在线程问题例子
public class MySource implements Runnable {
private Integer money;
public MySource(Integer money) {
this.money = money;
}
public void run() {
for (int i = 0; i < 50; i++) {
money = money - 1;
System.out.println(Thread.currentThread().getName() + "......" + money);
}
}
}
public class Main {
public static void main(String[] args) {
MySource mySource = new MySource(100);
Thread one = new Thread(mySource);
one.setName("this-A");
Thread two = new Thread(mySource);
two.setName("this-B");
one.start();
two.start();
}
}
启动程序后会发现有错乱的数据出现,例如图中的两个98。这是因为当A线程在执行线程任务获取到共享资源money
为99时,在对其进行减法操作之前,CPU切换到B线程执行,此时因为A线程还没进行减法,共享资源money
依然是99,当B线程进行减法并输出后结果便是98,而后CPU再次切换到A线程继续执行,A线程手握money
99,进行减法并输出结果后便是98,因此出现了两次98。
在多线程中类似的线程安全问题数不胜数,因此,要确保线程安全,应该避免在多线程环境下对共享资源的并发访问,即实现同步。
一、同步
1.同步代码块
同步代码块是指有synchronized
关键字修饰的代码块
synchronized(object) {
......
}
同步的前提:多个线程在同步中必须使用同一个锁。
被修饰的代码块会自动被加上内置锁,当线程获取锁时才可以运行同步代码块内的内容,运行结束会自动释放锁,否则线程进入锁定阻塞状态等待锁主线程释放锁。其锁可以是任意对象,一般情况下,都是选择共享资源对象作为锁对象。
需要注意的是,同步是一种高消耗的操作,应该尽量减少同步的内容,只将关键代码放入代码块中。
public void run() {
for (int i = 0; i < 50; i++) {
synchronized (this) {
money = money - 1;
System.out.println(Thread.currentThread().getName() + "......" + money);
}
}
}
2、同步函数
在函数上加上synchronized
关键字修饰,使得函数成为同步函数
public [static] synchronized void syncFunc() {
......
}
与同步代码块相似,同步函数会给函数自动添加锁,当该函数为静态时,锁为该函数所在对象的class对象,当为非静态时,锁为this即当前函数所在的对象。这是因为静态函数随着类文件的加载而加载的,此时不一定有该类的对象,但是一定有一个该类的字节码文件对象即class对象。
public class Singleton {
private Singleton() {}
private static Singleton singleton = null;
public static synchronized Singleton getSingleton() {
if (singleton == null){
singleton = new Singleton();
}
return singleton;
}
}
3.同步代码块与同步函数的区别
- 同步代码块可以使用任意的锁
- 同步函数只能使用this或者所在类的class对象作为锁
因此如果一个类中如果需要多个锁,或者说多个类需要公用同一个锁,这时只能使用同步代码块。个人建议尽量使用同步代码块。
4.死锁
死锁是指在同步代码块或同步函数中嵌套了不同锁的同步代码块或同步函数所导致的线程无限等待阻塞状态,撸个例子先
public class MyLock {
public static final Object LOCKA = new Object();
public static final Object LOCKB = new Object();
}
public class DeadLock implements Runnable {
private boolean flag;
public DeadLock(boolean flag) {
this.flag = flag;
}
public void run() {
if (flag) {
while (true) {
synchronized (MyLock.LOCKA) {
System.out.println("lockA");
synchronized (MyLock.LOCKB) {
System.out.println("lockB");
}
}
}
} else {
while (true) {
synchronized (MyLock.LOCKB) {
System.out.println("lockB");
synchronized (MyLock.LOCKA) {
System.out.println("lockA");
}
}
}
}
}
}
public class Main {
public static void main(String[] args) {
DeadLock deadLockOne = new DeadLock(true);
DeadLock deadLockTwo = new DeadLock(false);
Thread one = new Thread(deadLockOne);
Thread two = new Thread(deadLockTwo);
one.start();
two.start();
}
}
运行程序之后结果为
这是因为当线程A进入同步代码块获取了锁LOCKA后,CPU刚好切换到了线程B,此时因为flag为false所以线程B进入了else中的同步代码块中获取了锁LOCKB,此时线程B想进入内嵌的同步代码块时需要锁LOCKA,但此时LOCKA被线程A拿着,而当切换回线程A继续执行想进入内嵌的同步代码块时需要的锁LOCKB被线程B拿着,造成了死锁。
5.Lock&Condition
在上文中,我们通过关键字synchronized
来实现了同步,而synchronized
的锁操作是隐式的,是在JVM层面上实现的,当在同步过程中抛出异常,会自动释放锁。但是它有不完美的地方–无法中断(interrupt
)一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行。在某些情况下这会带来困扰。
为了解决这些问题,java的开发人员开发了java.util.concurrent.lock框架,其中ReentrantLock
类实现了Lock
,它拥有与synchronized
相同的并发性和内存语义,但是添加了类似轮询锁、定时锁等候和可中断锁等候的一些特性。此外,在激烈竞争中还提供更高的效率。但是有一点需要注意的是,Lock
必须在finally
块中释放锁。否则,如果受保护的代码抛出异常,锁就有可能永远得不到释放,从而导致死锁。
现在我们又学习到了一种实现同步的方法,那么什么时候应该去使用ReentrantLock
呢?当在高度争用的情况下可以考虑去使用它,但是请记住,大多数synchronized
块几乎从来没有出现过争用,所以可以把高度争用放在一边。当根据实际情况确定synchronized
确实不合适,才去使用ReentrantLock
,而不要仅仅是假设如果使用ReentrantLock
"性能会更好"。我们首先要把事情做好,然后再考虑是不是有必要做得更快。
- 通过Lock实现同步
public class MySource implements Runnable {
Lock lock = new ReentrantLock();
private Integer money;
public MySource(Integer money) {
this.money = money;
}
public void run() {
for (int i = 0; i < 50; i++) {
lock.lock();
try {
money = money - 1;
System.out.println(Thread.currentThread().getName() + "......" + money);
}
finally {
lock.unlock();
}
}
}
}
- Condition
Condition
类可以通过lock.newCondition()
获取,它提供了await()
、signal()
、signalAll()
等方法,其使用方式与wait()
、notify()
、notifyAll()
类似,在争用激烈时,提供了更高的效率。
二、多线程通信
多线程通信是指多个线程都在处理同一个资源,但是处理的线程任务不一样。
这里我们可以通过一个典型的例子"生产者、消费者模型"来说明,在这个模型中会出现一种需求"每当消费者需要一个面包,生产者就生产一个面包",这个时候我们需要在同步中使用***等待唤醒机制***。
先来学习以下几个方法
- wait()
让线程转入等待阻塞状态,并将线程临时存储到线程池中。
- notify()
唤醒指定线程池中的任意一个线程。
- notifyAll()
唤醒指定线程池中的所有线程。
wait()
、notify()
、notifyAll()
必须使用在同步中。因为它们是用来操作同步锁上的线程的状态的,所以在使用这些方法时,必须表识它们所属的锁:锁对象.wait()
、锁对象.notify()
、锁对象.notifyAll()
,相同锁的notify()
可以唤醒的wait()
。当然,当锁为this
时,可省。
1.单生产、单消费
public class MySource {
/**产品个数*/
private Integer count = 1;
/**生产消费标识符*/
private boolean flag = true;
/**
* 生产方法
*/
public synchronized void produce() {
while(true) {
if (flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count ++;
System.out.println(Thread.currentThread().getName() + "......" + "生产1个面包,目前面包数:" + count);
flag = true;
notify();
}
}
/**
* 消费方法
*/
public synchronized void consumption() {
while (true) {
if (!flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count -- ;
System.out.println(Thread.currentThread().getName() + "......" + "消费了一个面包,目前面包数:" + count);
flag = false;
notify();
}
}
}
public class Producer implements Runnable{
private MySource mySource;
public Producer(MySource mySource) {
this.mySource = mySource;
}
public void run() {
mySource.produce();
}
}
public class Consumer implements Runnable {
private MySource mySource;
public Consumer(MySource mySource) {
this.mySource = mySource;
}
public void run() {
mySource.consumption();
}
}
public class Main {
public static void main(String[] args) {
MySource mySource = new MySource();
Producer producer = new Producer(mySource);
Consumer consumer = new Consumer(mySource);
Thread pt = new Thread(producer);
Thread ct = new Thread(consumer);
pt.start();
ct.start();
}
}
如代码所示,我们假设了一开始是有1个面包的,然后开启两条线程分别担任消费者和生产者的角色,生产者通过标识符发现还有面包,就进入等待阻塞状态等待唤醒,消费者要买面包如果发现者有面包,就买走,买走后唤醒生产者让其进行生产,如果发现没有面包了就进入等待阻塞状态等待唤醒。生产者被唤醒后继续进行线程任务生产面包,生产后唤醒消费者买走面包。
2.多生产、多消费
这个例子是多线程担任了生产者或消费者,就是说有多个面包师傅和多个买面包的人,这种情况下不能像上述的单生产单消费者那样每当被唤醒就直接去生产或者消费。如果按照上面的例子,会出现两个问题:
- 重复生产、重复消费
有这么一种情况:
消费者A拿了同步锁进入同步代码块发现有面包就买走了一个并唤醒线程池中的线程但是此时并没有等待的线程,消费者B进来之后发现面包没了进入等待阻塞状态释放了锁,这个时候消费者A再进来一次发现没面包了进入了等待阻塞状态。
面包师傅A拿到同步锁进入同步代码块看到面包没了就生产了一个面包并唤醒了消费者A
消费者A一起来二话不说买走一个面包,恰好唤醒了等待池中的消费者B,消费者B一起来也二话不说买走了一个面包,造成了只有一个面包却重复消费的局面。
同样的情况也会出现再生产线程中,这就造成了重复生产、重复消费的问题。
解决很简单,通过while
来循环判断面包是否存在
while(flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
- 全部线程都进入等待阻塞状态
修复了重复生产、重复消费之后有这么一种情况:
面包师傅A拿到同步锁进入同步代码块发现有面包进入等待阻塞状态中,此时面包师傅B接着拿到锁进来又进入了等待阻塞状态中。
消费者A拿了同步锁进入同步代码块发现有面包就买走了一个并唤醒线程池中的面包师傅A后自己又再一次进入同步代码块中发现面包没了进入等待阻塞状态释放锁,接着消费者B拿了同步锁进到同步代码块发现没面包了也进入等待阻塞状态
面包师傅A进入同步代码块发现面包没了做了一个面包,恰好唤醒的是面包师傅B,面包师傅A执行完后再一次进入同步代码块发现有面包了就进入等待阻塞状态释放锁,然后面包师傅B进入同步代码块发现有面包也进入了等待阻塞状态。
此时,四条线程全部进入了等待阻塞状态,程序"死了"。
简单的解决方法是将notify()
改为notifyAll()
方法,即唤醒线程池中的所有线程即可。
- 存在的问题
最终我们通过notifyAll()
方法来唤醒了线程中的所有线程,但是显然存在一个问题,将多余的线程唤醒了,例如面包师傅A做完面包只要唤醒消费者就好了,但是现在把面包师傅B也唤醒了。这一点我们在下一节中再改善。
public class MySource {
/**当前个数*/
private Integer count = 1;
/**生产消费标识符*/
private boolean flag = true;
/**面包总数*/
private Integer num = 1;
/**
* 生产方法
*/
public synchronized void produce() {
while(flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count ++ ;
num ++ ;
System.out.println(Thread.currentThread().getName() + "......" + "生产1个面包,目前面包数:" + count + ",是第" + num + "个面包");
flag = true;
notifyAll();
}
/**
* 消费方法
*/
public synchronized void consumption() {
while (!flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count -- ;
System.out.println(Thread.currentThread().getName() + "......" + "消费了一个面包,目前面包数:" + count + ",是第" + num + "个面包");
flag = false;
notifyAll();
}
}
public class Producer implements Runnable{
private MySource mySource;
public Producer(MySource mySource) {
this.mySource = mySource;
}
public void run() {
while (true) {
mySource.produce();
}
}
}
public class Consumer implements Runnable {
private MySource mySource;
public Consumer(MySource mySource) {
this.mySource = mySource;
}
public void run() {
while (true) {
mySource.consumption();
}
}
}
public class Main {
public static void main(String[] args) {
MySource mySource = new MySource();
Producer producer = new Producer(mySource);
Consumer consumer = new Consumer(mySource);
Thread producerOne = new Thread(producer);
Thread producerTwo = new Thread(producer);
Thread consumerOne = new Thread(consumer);
Thread consumerTwo = new Thread(consumer);
producerOne.start();
producerTwo.start();
consumerOne.start();
consumerTwo.start();
}
}
3.优化:多生产、多消费
想要优化上述的多生产、多消费其实也比较简单,通过Lock
、Condition
进行显式同步控制即可,只要在具体修改如下
public class MySource {
/**当前个数*/
private Integer count = 1;
/**生产消费标识符*/
private boolean flag = true;
/**面包总数*/
private Integer num = 1;
private Lock lock;
private Condition proCondition;
private Condition conCondition;
public MySource() {
lock = new ReentrantLock();
proCondition = lock.newCondition();
conCondition = lock.newCondition();
}
/**
* 生产方法
*/
public void produce() {
lock.lock();
try {
while (flag) {
try {
proCondition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count++;
num++;
System.out.println(Thread.currentThread().getName() + "......" + "生产1个面包,目前面包数:" + count + ",是第" + num + "个面包");
flag = true;
conCondition.signalAll();
} finally {
lock.unlock();
}
}
/**
* 消费方法
*/
public synchronized void consumption() {
lock.lock();
try {
while (!flag) {
try {
conCondition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count--;
System.out.println(Thread.currentThread().getName() + "......" + "消费了一个面包,目前面包数:" + count + ",是第" + num + "个面包");
flag = false;
proCondition.signalAll();
} finally {
lock.unlock();
}
}
}