1 并发问题概述
多线程共享同一份数据导致的并发错误。
- 什么是临界资源? 多个线程共享的同一份数据
问题复现:
- 多个线程正共享同一份数据
- 由于线程体(run())当中连续的操作未必能够在同一个时间片连续执行
- 操作一半时,时间片耗尽
- 此时另一个线程抢到时间片后,直接拿走并操作不完整的数据
- 得到错误数据
并发错误出现的
-
导火线:时间片突然耗尽
-
根本原因: 多个线程共享同一个对象
-
直接原因: 线程体(run())连续的操作未必能够连续执行
如例:
class ExampleConcurrentBefore{
public static void main(String[] args){
Person p1 = new Person("白云", "大妈");
PrinterThread pt = new PrinterThread(p1);
ChangeThread ct = new ChangeThread(p1);
pt.start();
ct.start();
}
}
class Person{
String name;
String gender;
public Person(String name, String gender){
this.name = name;
this.gender = gender;
}
@Override
public String toString(){
return name + ":" + gender;
}
}
//打印线程
class PrinterThread extends Thread{
Person p;
PrinterThread(Person p){
this.p = p;
}
@Override
public void run(){
while(true){
System.out.println(p);
}
}
}
//更改线程
class ChangeThread extends Thread{
Person p;
ChangeThread(Person p){
this.p = p;
}
@Override
public void run(){
boolean flag = true;
while(true){
//下面每一行都有失去时间片的可能,此时如果p.name执行了而gender没有,可能会造成变量的错乱
//若对象的属性错乱但下次时间片由打印线程获得,就会出现如图现象
if(flag){
p.name = "白云";
p.gender = "大妈";
}else{
p.name = "黑土";
p.gender = "大叔";
}
flag = !flag;
}
}
}
线程不能赖着时间片一直执行,也不能阻挡其他线程争抢时间片。要解决并发问题,必须加锁控制需要并发操作的对象,使得这个正在控制对象且时间片结束时还没有完成操作的线程,在下个时间片抢到后继续执行这个对象的操作,直到这个操作执行完毕。
2 如何解决并发问题
Java中的每个对象拥有属性、方法、一个唯一的锁标记(互斥锁)、锁池、等待池。
争抢锁标记就像夺旗战,拿不到锁的线程就在锁池中阻塞态等待。持有锁标记的线程在执行完操作(方法结束或代码块执行到右大括号)还回锁标记后消亡。此时等待的线程从锁池中被激活到就绪,抢夺锁标记,抢不到再回到锁池中阻塞态等待下次争抢。
2.1 可重入锁
可重复可递归调用的锁,修饰同一对象或类的情况下,在外层使用锁之后,在内层仍然可以使用,且不发生死锁。
2.1.1 互斥锁(Monitor)
互斥锁,又名锁标记、互斥锁标记、锁旗标、监视器,是一种可重入锁。
-
修饰符: synchronized(同步的)
-
修饰符使用方法:
- 修饰代码块(锁两个大括号之间的连续执行的操作):
synchronized(临界资源){ 需要连续执行的操作 }
例:
class ExampleConcurrentAfter{
public static void main(String[] args){
Person p1 = new Person("白云", "大妈");
PrinterThread pt = new PrinterThread(p1);
ChangeThread ct = new ChangeThread(p1);
pt.start();
ct.start();
}
}
class Person{
String name;
String gender;
public Person(String name, String gender){
this.name = name;
this.gender = gender;
}
@Override
public String toString(){
return name + ":" + gender;
}
}
//打印线程
class PrinterThread extends Thread{
Person p;
PrinterThread(Person p){
this.p = p;
}
@Override
public void run(){
while(true){
//与下面一致,同步修饰块要加载while循环内部
//若加在外部,这个线程一直执行,永远都不会释放锁标记
synchronized(p){
System.out.println(p);
}
}
}
}
//更改线程
class ChangeThread extends Thread{
Person p;
ChangeThread(Person p){
this.p = p;
}
@Override
public void run(){
boolean flag = true;
while(true){
synchronized(p){
if(flag){
p.name = "白云";
p.gender = "大妈";
}else{
p.name = "黑土";
p.gender = "大叔";
}
flag = !flag;
}
}
}
}
- 修饰方法(等价于加锁从方法第一行到最后一行,注意这个加锁是对同一对象的):
public synchronized void add(obj){
需要连续执行的操作
}
Vector、Hashtable等类线程安全的原因是它们底层所有操作数据的方法都有synchronized修饰,只允许单线程操作。如:
已知Vector类的add()和remove()都是synchronized修饰的,
有一个Vector对象叫 v,现有两个线程 t1 和 t2
当t1线程调用v对象的add() 方法开始执行了,但是还没执行结束,时间片耗尽
此时t2线程抢到了时间片:
t2能调用v对象的add()
t2能调用v对象的remove()
已知Vector类的add()和remove()都是synchronized修饰的
有两个Vector对象叫 v1 和 v2,现在有两个线程 t1 和 t2分别操作两个对象
当t1线程调用v1对象的add() 方法开始执行了,但是还没执行结束,时间片耗尽
此时t2线程抢到了时间片:
t2不能调用v1对象的add()、remove()
t2能调用v2对象的add()、remove()
-
注意: 形成互斥的线程操作都要加锁。
- 修饰代码块,锁的是一个临界资源,即互斥线程加synchronized时括号中要锁的临界资源名字要保持一致。
- 修饰非静态方法,锁的是调用这个方法的对象,其他不加锁的方法并发不受影响。
- 修饰静态方法,锁的是调用这个静态方法的类,其他不加锁的类方法并发不受影响。
-
synchronized特性: 这个被修饰的方法本身不会被子类的方法继承,也就是说子类继承到这个方法后不能确保是线程安全的,只能通过重新覆盖这个方法后加上这个修饰符,才可以确保不会出现并发问题。
2.1.2 并发包中的可重入锁
JDK5.0之后的java.util.concurrent.locks.ReentrantLock的锁包中定义的可重入锁。
- lock() 上锁
- unlock() 释放锁
import java.util.concurrent.locks.*;
class ExampleConcurrentReentrantLock{
public static void main(String[] args){
//访问同一个操作,共享一把锁
Lock lock = new ReentrantLock();
Person p1 = new Person("白云", "大妈");
PrinterThread pt = new PrinterThread(p1, lock);
ChangeThread ct = new ChangeThread(p1, lock);
pt.start();
ct.start();
}
}
class Person{
String name;
String gender;
public Person(String name, String gender){
this.name = name;
this.gender = gender;
}
@Override
public String toString(){
return name + ":" + gender;
}
}
//打印线程
class PrinterThread extends Thread{
Person p;
Lock lock;
PrinterThread(Person p, Lock lock){
this.p = p;
this.lock = lock;
}
@Override
public void run(){
while(true){
//lock若加在外部,这个线程一直执行,永远都不会释放锁标记
lock.lock();
try{
System.out.println(p);
}finally{
lock.unlock();
}
}
}
}
//更改线程
class ChangeThread extends Thread{
Person p;
Lock lock;
ChangeThread(Person p, Lock lock){
this.p = p;
this.lock = lock;
}
@Override
public void run(){
boolean flag = true;
while(true){
lock.lock();
try{
if(flag){
p.name = "白云";
p.gender = "大妈";
}else{
p.name = "黑土";
p.gender = "大叔";
}
flag = !flag;
}finally{
lock.unlock();
}
}
}
}
2.1.3 懒汉式(懒加载)
public class TestSingleton{
public static void main(String[] args){
//验证:
Moon x = Moon.getMm();
Moon y = Moon.getMm();
Moon z = Moon.getMm();
System.out.println(x == y);
System.out.println(y == z);
}
}
//只有一个月亮
class Moon{
//私有化构造方法
//private:私有化构造方法,防止类体之外别人随意new Moon对象获取到
private Moon(){}
//private:防止类体之外别人随意的对静态属性赋值,若设置为“Moon.mm = null;”就尴尬了
//static:防止死循环* Moon-> mm -> mm -> mm......
//不要new,懒汉式就是在需要时才new,如果不需要,就不new了
//将new的位置转到get方法中
private static Moon mm;
//getter
//public:封装
//static: 防止方法依赖于对象,只有类才可以定义
//synchronized:此时上面的mm对象并没有初始化,为防止并发的线程共同操作,new出不同的Moon对象
public static synchronized Moon getMm(){//月亮.getMm();
mm = (mm == null? new Moon() : mm);
return mm;
}
}
2.1.4 如何理解同步/异步
- 同步理解为在执行完一个函数或方法后,程序阻塞,直到等到系统返回值或消息之后,程序才往下执行其他的命令。 (严格地排好队,一个一个来)
- 异步是执行完函数或方法后,只要向系统委托一个异步过程,不必阻塞去等待返回值或消息,程序直接向下执行其他命令。当系统接收到返回值或消息后,系统会自动触发委托的异步过程,完成一个完整的流程。 (互不干涉,至少两个线程,并行着来)
2.2 锁池
一个对象可以被多个线程操作,若在一个线程未操作完的情况下与之互斥的另一个线程接着操作这个对象,就会发生并发问题。
在上面说到可以使用synchronized修饰符修饰一块代码或者一个方法。当几个具有互斥关系线程一同操作这个对象,有一个线程可以真正操作这个对象(即争抢到这个对象独有的一个锁标记)。另外没有抢到锁标记的线程只能在锁池中等待(此时这些线程为阻塞状态)。
-
当线程争抢到时间片,具有锁标记的线程就执行它对这个对象的操作。
-
具有锁标记的线程执行结束后,
- 若时间片尚未耗尽,锁标记归还给对象,锁池中阻塞等待的线程就进入就绪状态争抢锁标记;
- 若时间片已被耗尽,就绪态线程争抢到锁标记后,这个线程在下次争抢到时间片后再执行;
-
时间片耗尽但线程还没有执行结束,则保留现场信息到下次线程再次争抢到时间片再执行操作,时间片耗尽不一定归还锁标记。
-
总之,被synchronized修饰的方法,线程得以使用对象操作方法的前提是:具有锁标记、争抢到时间片。而具有锁标记的线程才有资格参与时间片的争抢。时间片和锁标记并非一个概念。
具有锁标记的线程运行中
具有锁标记的线程运行结束时
- 死锁(DeadLock)问题:
互斥锁标记使用过多或使用不当时,就会造成多个线程相互持有对方想要申请的资源不释放的情况下,又去申请对方已经持有的资源,从而双双进入对方持有资源的锁池当中,结果产生永久的阻塞。
例:一条东西走向的路,两辆车从路两端向路中间走,走到路中间时,路东边的车想到路西边,路西边想到路东边,两辆车互不相让,造成“死锁”。
public class DeadLockOccur{
public static void main(String[] args){
Road rd = new Road();
Road.Car1 c1 = rd.new Car1();
Road.Car2 c2 = rd.new Car2();
c1.start();
c2.start();
}
}
class Road{
//路东、路西两个资源
private Object rdEast = new Object();
private Object rdWest = new Object();
class Car1 extends Thread{
@Override
public void run(){
System.out.println("车1自西向东开在路上。");
synchronized(rdWest){
System.out.println("车1遇到了车2,此时车1在路西。");
//耗时操作,为制造两辆车相遇的场景,防止线程执行过快
try{
sleep(500);
}catch(Exception e){
e.printStackTrace();
}
synchronized(rdEast){
System.out.println("车1开到路东。");
}
}
System.out.println("车1开完这一段路。");
}
}
class Car2 extends Thread{
@Override
public void run(){
System.out.println("车2自东向西开在路上。");
synchronized(rdEast){
System.out.println("车2遇到了车1,此时车2在路东。");
try{
sleep(500);
}catch(Exception e){
e.printStackTrace();
}
synchronized(rdWest){
System.out.println("车2开到路西。");
}
}
System.out.println("车2开完这一段路。");
}
}
}
上面程序的内存图:
车1和车2是可以顺利到达路西和路东(各自成功获取一个临界资源),但之后程序就进入阻塞,因为他们都没有释放自己手里的资源而想去获取对方的资源,造成双双进入对方的锁池阻塞的窘况。
如何解决这样的尴尬局面?
2.3 等待池
要解决死锁问题,最关键的是一个空间和三个方法。
-
一个空间: 等待池,当前放弃所持有锁标记的线程,进入等待池等待,直到被唤醒notify()/notifyAll()才会出来继续争抢锁标记继续执行。
-
三个方法: Object类中(每个对象都有,不是Thread类)的wait()、notify()、notifyAll()
-
wait()
让当前线程放弃已持有的锁标记,并且进入调用方法的那个对象的等待池中。这时线程阻塞,进入等待池等待,直到被带有锁标记的线程唤醒notify()/notifyAll()才会出来,到锁池阻塞,待这个具有锁标记的线程释放锁标记,变为就绪态争抢得到锁标记后继续执行。从运行态到阻塞态,一定要做异常处理。 -
notify()
带有锁标记的线程从调用方法的那个对象的等待池当中随机唤醒一个线程,线程进入锁池,若锁池中有其他阻塞的线程,待带有锁标记的线程归还锁标记后,锁池中线程和这个被唤醒的线程一起变为就绪状态来争夺锁标记。 -
notifyAll()
带有锁标记的线程从调用方法的那个对象的等待池中唤醒所有线程,这些线程进入锁池,若锁池中有其他阻塞的线程,待带有锁标记的线程归还锁标记后,锁池中线程和等待池被唤醒的线程一起变为就绪状态来争夺锁标记。
-
-
这三个方法都必须在已持有锁标记的前提下才能使用,所以它们必须出现在synchronized修饰的程序块或方法中。
-
调用了wait(),与之互斥的线程的互斥锁中需调notify()/notifyAll(),在自己的互斥锁中既调用notify()又调用wait()无意义。
-
线程体里面的代码不一定一次执行结束。若加了wait(),后面的代码将在下次被唤醒抢到锁标记和时间片后再执行。
-
锁池和等待池的异同:
-
锁池和等待池都是Java当中每个对象都有一份的空间,用来存放线程的空间
-
进入的时候是否需要释放资源
锁池:锁池不需要释放任何资源,这也是导致死锁的原因
等待池:需要先归还锁标记,才能进入等待池 -
离开的时候是否需要调用方法
锁池:不需要,当携带锁标记的线程归还锁标记,锁池中的线程可自行离开
等待池:需要notify()/notifyAll() -
离开之后去往何方
锁池:返回就绪态,参与争抢锁标记
等待池:被notify()/notifyAll()的线程进入锁池,若锁池中有其他阻塞的线程,待带有锁标记的线程归还锁标记后,锁池中线程和等待池被唤醒的线程一起变为就绪状态来争夺锁标记。
-
2.3.1 使用等待池解决死锁问题
- 解决例:
//注释的代码为另外一种解决方法,让路西侧车1先放弃资源
public class DeadLockWorkOut{
public static void main(String[] args){
Road rd = new Road();
Road.Car1 c1 = rd.new Car1();
Road.Car2 c2 = rd.new Car2();
c1.start();
c2.start();
}
}
class Road{
//路东、路西两个资源
private Object rdEast = new Object();
private Object rdWest = new Object();
class Car1 extends Thread{
@Override
public void run(){
System.out.println("车1自西向东开在路上。");
synchronized(rdWest){
System.out.println("车1遇到了车2,此时车1在路西。");
//耗时操作,为制造两辆车相遇的场景,防止线程执行过快
try{
sleep(500);
}catch(Exception e){
e.printStackTrace();
}
/*
try{
rdWest.wait();
}catch(Exception e){
e.printStackTrace();
}*/
synchronized(rdEast){
System.out.println("车1开到路东。");
rdEast.notify();
}
}
System.out.println("车1开完这一段路。");
}
}
class Car2 extends Thread{
@Override
public void run(){
System.out.println("车2自东向西开在路上。");
synchronized(rdEast){
System.out.println("车2遇到了车1,此时车2在路东。");
try{
sleep(500);
}catch(Exception e){
e.printStackTrace();
}
try{
rdEast.wait();
}catch(Exception e){
e.printStackTrace();
}
synchronized(rdWest){
System.out.println("车2开到路西。");
//rdWest.notify();
}
}
System.out.println("车2开完这一段路。");
}
}
}
执行结果:
图例:
2.4 例题
交替打印左右脚,模仿人的走路过程。
public class ExempleWalking{
static Object flag = new Object();
static LeftThread lt = new LeftThread(flag);
static RightThread rt = new RightThread(flag);
public static void main(String[] args){
//启用左脚线程
lt.start();
}
}
class LeftThread extends Thread{
Object flag;
public LeftThread(Object flag){
this.flag = flag;
}
@Override
public void run(){
//防止右脚先走造成死锁
ExempleWalking.rt.start();
//锁套在外面的原因是使线程只因为wait()阻塞而归还锁标记
//若再加上因synchronized执行结束归还锁标记,多一种偶发情况,无法控制
synchronized(flag){
while(true){
System.out.println("左脚");
try{
flag.wait();
}catch(Exception e){
e.printStackTrace();
}
flag.notify();
}
}
}
}
class RightThread extends Thread{
Object flag;
public RightThread(Object flag){
this.flag = flag;
}
@Override
public void run(){
synchronized(flag){
while(true){
System.out.println(" 右脚");
flag.notify();
try{
flag.wait();
}catch(Exception e){
e.printStackTrace();
}
}
}
}
}
3 适用于并发的集合
前面学习到的集合,除了Vector和Hashtable不支持多线程操作,其他的均支持,但是会在多线程操作的情况下会出现并发错误。
- JDK5.0后,将线程不安全的实现JCF接口的集合类变成线程安全的集合类的实现方法:
List < E > list = Collections.synchronizedList(list);
实现了其他接口的集合类可以使用下面的方法:
Collections.syncrhonizedCollection();
Collections.syncrhonizedSet();
Collections.syncrhonizedSortedSet();
Collections.syncrhonizedMap();
Collections.syncrhonizedSortedMap();
上面的方法都是使用包装类定义的,这样被包装起来,当再有自己定义的集合实现了JCF的某个接口,不用再自己写一个synchronizedXXX,直接使用这种对应的方法使其变成安全的。
List实现集合类,HashMap、HashSet使用了这个方法后,线程方面的加锁情况就会与同组的Vector,Hashtable相同。例如Hashtable是在分小组之前加锁来防止并发错误的,此时HashMap、HashSet的加锁与Hashtable就相同了。
- 但如何使锁加在每一个Hash小组上而不是在分组之前就加,或在多线程高并发情况下,如何直接创建一个线程安全集合?
使用java.util.concurrent.*;并发包保证集合的并发操作的数据安全。
常用的并发集合:- ConcurrentHashMap;
- ConcurrentSkipListMap;
- ConCurrentSkipListSet;
- CopyOnWriteArrayList;
- CopyOnWriteArraySet;
- ConcurrentLinkedQueue;