目录
1.多线程可能会造成的安全问题
2. static共享变量
3.同步代码块
4.同步方法
5.使用Lock手动加锁和解锁
6.死锁
1.多线程可能会造成的安全问题
场景:三个窗口同时售卖100张电影票,使用线程模拟。
public class MyThread extends Thread{//ticketnum表示当前正在售卖第几张票public int ticketnum=0;@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (ticketnum<100){ticketnum++;System.out.println(Thread.currentThread().getName()+"正在售卖第"+ticketnum+"张票");//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}//模拟三个窗口同时售票
public class Main {public static void main(String[] args) {//创建三个线程模拟三个窗口Thread t1=new MyThread();Thread t2=new MyThread();Thread t3=new MyThread();t1.setName("窗口1");t2.setName("窗口2");t3.setName("窗口3");//模拟三个窗口售票t1.start();t2.start();t3.start();}
}
运行结果:
从运行结果可以看到,如果这样设计多线程卖票,会出现三个窗口出售同一张票,在实际情况下肯定是不能够出现这种问题的。那么为什么会出现重复售票的情况呢?
这是因为ticketnum是普通成员变量,也就意味着每个线程中ticketnum是独立的,即相当于每个线程各有100张票进行售卖,但实际上三个窗口应当共同售卖这100张票。
2. static共享变量
我们可以通过将ticketnum变成静态成员变量来解决这个问题。静态成员变量是共享的,这样可以保证这三个线程共同售卖这100张票:
public class MyThread extends Thread{//ticketnum表示当前正在售卖第几张票//使用static将其变为共享的public static int ticketnum=0;@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (ticketnum<100){ticketnum++;System.out.println(Thread.currentThread().getName()+"正在售卖第"+ticketnum+"张票");//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}
此时再次运行代码,运行的结果为:
此时运行结果还是有问题,不仅还是会重复,而且在卖完第100张票后又出现了还在卖第99张票的情况,并且第96、98张票并没有显示被卖出。这又是为什么呢?
这是由于线程执行的随机性导致的,在java中,多线程的执行是抢占式调度的,哪个线程能竞争到CPU完全是随机的,并且线程在执行过程中CPU也是有可能被其他线程抢走的。
下面在解释时线程1、2、3并不对应上面例子中的窗口1、2、3,只是为了解释原因。
先来解释为什么将ticketnum变成共享的还是会出现重复售卖的现象:
虽然在运行结果中窗口3、1、2重复出售了第97张票,但实际上ticketnum并不会重复,因为已经设置为共享资源了,输出结果中出现重复是因为在输出结果时出现了问题。假设目前都是从第一张票进行售卖,即ticketnum还是初始值0。线程1先抢到了CPU,执行ticketnum++,此时ticketnum的值变为1,然后线程1还没有来得及执行输出的操作分配给线程1的CPU占用时间就耗尽了;此时线程2成功抢到CPU,也是执行ticketnum++,由于是共享资源,所以这里的自增是在1的基础上自增的,所以此时ticketnum的值就变为了2,同样线程2还没有来得及执行输出的操作CPU就被线程3抢走了;线程3也执行ticketnum++,此时ticketnum就变成了3,然后CPU占用时间到。
此时线程1再次抢到CPU,执行输出操作,但由于ticketnum变成了3,所以线程1会输出“正在售卖第3张票”,然后执行sleep方法休眠100ms,CPU被强制释放;此时线程2抢到了CPU执行输出操作,ticketnum的值还是3,所以线程2也会输出“正在售卖第3张票”,随后也进入100ms的休眠;由于线程1和线程2都进入了休眠,只有线程3在竞争CPU,所以线程3会抢到CPU,也会输出“正在售卖第3张票”。
由此可见,虽然输出结果显示三个线程收买了同一张票,但实际上售卖了三张不同的票,只是输出结果时出现了问题。
再来解释为什么输出结果中有的票没有售出显示:
其实从上面的过程我们可以看到,ticketnum是会一直自增1的,但由于打印结果时出现了CPU抢占问题,导致ticketnum在自增后不能及时输出结果,本来应该是自增之后就输出,变成了三个线程滞后输出了相同的结果。所以重复输出相同的结果已经覆盖了那些没有显示的输出。
最后解释为什么已经售完了第100张票,还会出售第99张票:
假设此时正在出售第97张票,线程1执行完ticketnum++后ticketnum变成了98。然后线程2抢占了CPU并执行ticketnum++,此时ticketnum变成了99,接着开始执行输出操作;在执行输出操作时,需要先获取要输出的内容,此时读到的ticketnum是99,但还没有来得及执行println时间就耗尽了,此时线程1抢到了CPU,准备执行输出操作,获取到的ticketnum自然也是99,还没有来得及执行println,CPU就被线程3抢走了,线程3不仅执行了ticketnum++使其变为了100,还完整执行了输出操作,随后线程3进入100ms的休眠状态。此时线程1抢到了CPU,继续执行println,但由于之前读到的值是99,所以线程1会输出“正在售卖第99张票”,随后也进入100ms的休眠状态。最后是线程2抢到CPU,继续执行println,也输出“正在售卖第99张票”。所以输出结果时会先出现线程3输出“正在售卖第100张票”,然后是线程1输出“正在售卖第99张票”,最后是线程2输出“正在售卖第99张票”。
那怎样解决这种问题呢?
3.同步代码块
上面这三种情况归根结底还是因为程序在运行过程中其他线程能够修改共享变量的值,导致前后内容不一致的情况,也就是不能保证代码执行的原子性。如果我们在执行的过程中进行加锁,当一个线程未使用完共享资源之前不允许其他线程访问这个共享资源,那不就可以解决这个问题了,这就需要同步代码块来帮忙。
同步代码块的格式:
synchronized (锁对象) {//要执行的代码}
其中锁对象可以是任意一个对象,因为锁对象就相当于这把锁是什么样的,是什么品牌的锁,什么形状的锁不重要,关键在于要锁住所有可能用到共享资源的代码,保证在这些代码执行完之前不会有其他线程干扰代码的执行。同时锁对象又必须是唯一的或者共享的,虽然锁的样式可以不同,但是锁必须是唯一的或者共享的,就比如一个房间有一把黑锁和一把白锁,有两个人要进入这个房间,一个人只认黑锁,如果没有黑锁就认为房间没有上锁可以进入,而另一个人只认白锁;此时第一个人进入到房间并给房间加了黑锁,第二个人由于只认白锁,所以会认为房间没有上锁也进入到这个房间,此时锁就失去了作用。
同步代码块在执行时会自动加锁,当其中的代码块都执行完之后才会自动解锁。
接着之前卖电影票的案例,可以将代码修改为:
public class MyThread extends Thread{//ticketnum表示当前正在售卖第几张票//使用static将其变为共享的public static int ticketnum=0;//锁对象,用于加锁操作public static Object object=new Object();@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止//对卖票过程进行加锁synchronized (object){while (ticketnum<100){ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}}
}
这里要注意要把整个while循环放到同步代码块中,因为while的判定条件也用到了共享变量ticketnum,凡是可能用到共享资源的代码都要放到同步代码块中。
运行结果为:
如果仅把ticketnum++和输出操作放入同步代码块也会出现线程安全问题,就是下面这样:
//模拟卖票过程,总共100张票,售完为止while (ticketnum<100){//对卖票过程进行加锁synchronized (object){ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}
运行结果为:
这是因为while中的判定条件并没有在同步代码块中,所以在执行判定条件时就无法保证线程安全。假设已经执行到卖出第99张票,随后线程1执行while的判定条件,99<100,所以线程1再次进入循环,但此时还没有来得及执行同步代码块线程2就抢走了CPU。此时线程2也执行while的判定条件,由于同步代码块还没有执行,就意味着还没有加锁,所以线程2也可以拿到ticketnum的值为99,所以线程2也可以再次进入循环,然后又和线程1一样,还没有来得及执行同步代码块线程3就抢走了CPU,同理线程3也拿到了ticketnum的值为99,也再次进入了循环。
然后假设CPU又被线程1抢走了,此时线程1执行同步代码块,虽然加锁了,其他线程在同步代码块执行期间也都不能再次访问共享变量ticketnum,但是在这之前线程2和线程3都因为while没有保证线程安全都再次进入了循环,所以线程1、2、3都会执行ticketnum++和输出操作。
只不过不同的是,线程1执行同步代码块之后ticketnum变为了100,然后输出“正在售卖第100张票”,执行结束后进入100ms的休眠状态,然后线程2抢到CPU并执行同步代码块,此时ticketnum变成了101,然后输出“正在售卖第101张票”,随后线程2也进入休眠状态,线程3抢占CPU并执行同步代码块,ticketnum变成了102,然后输出“正在售卖第102张票”。
但是如果把整个while都放到同步代码块中还是会有问题,因为sleep也在循环体中,这么做虽然线程也会进入阻塞状态,但这期间由于还没有解锁,其他线程并不能对这个线程所持有的共享资源进行操作,所以其他线程抢到CPU也没有用,最后的运行结果会显示为这个线程售完了所有的票,相当于这个窗口垄断了整个售票部。
那怎样才能既保证所有可能用到共享资源的代码都在代码块中,也能保证sleep不在同步代码块中呢?
sleep在while循环体中,要想不让sleep在同步代码块中就不能让整个while在同步代码块中,这样做就不能让ticketnum这个共享变量出现在while的判定条件里,所以我们可以在while中使用if-else判断语句,将ticketnum<100这个判定条件转移到if中,这样就不需要对整个while进行加锁了:
@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (true){//对卖票过程进行加锁synchronized (object){//加入if-elseif(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}}//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
运行结果为:
4.同步方法
也可以使用同步方法,将同步代码块中的代码放到同步方法中,同步方法可以不用指定锁对象,在运行时会自动指定锁对象,如果使用的变量是非静态的,那么锁对象就是this,如果使用的变量是静态的,那么锁对象就是这个类的Class对象。下面是同步方法的格式:
public synchronized void 方法名(){//要执行的代码}
不过要注意的是,如果使用了循环并且判定条件中使用了共享变量,或者在循环中使用了break或continue,那么要把整个循环都要放在同步方法中。前者上面讲过了,整个循环体都要放在同步代码块中,同步方法中肯定也是要把整个循环体都放进去;后者虽然不用将整个循环体放到同步代码块中,但必须都要放在同步方法中,因为break和continue只能在循环体中使用,如果在循环外使用会报错。
那如果使用实现Runnable接口的方式实现电影院售票呢?下面是代码:
public class MyRun implements Runnable{int ticketnum=0;//对卖票过程进行加锁public synchronized void lock(){//模拟卖票过程,总共100张票,售完为止while (true){if(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}}//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}@Overridepublic void run() {lock();}
}//实现模拟卖票
public class Main {public static void main(String[] args) {//创建MyRun任务类的对象Runnable r=new MyRun();//创建三个线程模拟三个窗口Thread t1=new Thread(r);Thread t2=new Thread(r);Thread t3=new Thread(r);t1.setName("窗口1");t2.setName("窗口2");t3.setName("窗口3");//模拟三个窗口售票t1.start();t2.start();t3.start();}
}
运行结果为:
如果使用Runnable接口,那么ticketnum就没必要使用静态的了,普通成员变量就可以了,因为Runnable的实现类MyRun仅创建了一个实例,这就谈不到独立和共享变量的问题了,前面的MyThread创建了三个实例,所以必须让ticketnum变成静态的使其成为共享变量。所以对于MyRun的同步方法而言锁住的是非静态变量,那么锁对象就是this,也就是MyRun的实例;如果在MyThread中使用同步方法,那么锁住的就是静态变量,锁对象就是MyThread.class。
由上面的运行结果会发现,由于把整个循环体都放进了同步方法中,sleep也放被迫进去了,会导致线程1卖完了所有的票,所以遇到循环需要考虑使用同步方法会不会产生其他影响。
如果是使用lambda表达式,那么直接将资源类的方法变成同步方法即可:
//资源类Ticket
public class Ticket {int ticketnum=0;//直接加上synchronized关键字使其变为同步方法public synchronized void sale(){//模拟卖票过程,总共100张票,售完为止while (true){if(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}}try {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}//实现买票过程
public class Main {public static void main(String[] args) {//创建资源类对象Ticket ticket=new Ticket();//在创建线程时使用lambda表达式Thread t1=new Thread(()->{ticket.sale();},"线程1");//使用方法引用Thread t2=new Thread(ticket::sale,"线程2");//启动线程t1.start();t2.start();}
}
多线程的实现方式详解见另一篇博客多线程学习-CSDN博客的第三部分。
5.使用Lock手动加锁和解锁
上面使用同步代码块会自动加锁和解锁,那有没有办法可以自己控制加锁和解锁的时机呢?
java在JDK1.5之后提供了一个新的锁对象Lock,他有两个方法lock和unlock,一个是手动加锁,另一个是手动解锁。但是Lock本身是一个接口不能实例化对象,所以我们在使用时需要用到它的一个实现类ReentrantLock来实例化对象。也就是:
Lock l=new ReentrantLock();
并且还能同时设置是否为公平锁。 所谓公平锁和非公平锁,就是看加锁的过程是否公平,公平就是先来先加锁,非公平就是允许后来的线程插队,先获得锁;一般使用非公平锁,比如一个线程先来但是要运行1小时,另一个线程后来只需要运行1秒,如果使用公平锁那么第二个线程需要等1小时才能加锁,而使用非公平锁则允许第二个线程先获得锁,且第一个线程只用等1秒也能获取到锁,总体等待时间由1小时缩短为1秒。可以用以下代码来设置:
//true表示设置为公平锁,false表示设置为非公平锁
Lock l=new ReentrantLock(true);
默认的锁是非公平锁,可以查看源码:
回到上面的例子,如果用Lock来实现同步代码块是不是仅需要在同步代码块中要执行的代码的前后分别加上lock和unlock就可以了呢?我们可以试一试:
这是使用同步代码块的run方法:
@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (true){//对卖票过程进行加锁synchronized (object){if(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}}//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
这是在前后加上lock和unclock的方法:
注意创建的lock对象也必须是静态的,这个就相当于锁对象,必须唯一,当然如果使用Runnable接口实现的那就可以是普通变量。
public class MyThread extends Thread{//ticketnum表示当前正在售卖第几张票//使用static将其变为共享的public static int ticketnum=0;//锁对象,用于加锁操作public static Lock lock=new ReentrantLock();;@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (true){//对卖票过程进行加锁lock.lock();if(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}lock.unlock();//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}
运行结果:
乍一看没有什么问题,但是仔细看就会发现程序并没有停止运行,这是哪里出现了问题呢?
在上面的代码中,如果判断ticketnum>=100就会break退出循环,但在判断之前执行了加锁操作,break之后循环跳出,unlock解锁操作自然就没有执行,所以此时还处在上锁状态,需要将锁释放程序才能停止运行。
所以不只是在要执行的代码前后加上lock和unlock那么简单。在上面的例子中我们可以在break前加上一个unlock操作就能解决问题,但这样做unlock就会出现两次,如果有多个break那就需要重复加上多个unlock。那怎样只需要写一个unlock就能保证加的锁最后都能被释放呢?
保证加的锁最后都能被释放换句话讲就是无论中间执行什么代码,最后解锁的操作都会执行,这和try-catch-finally中的finally部分很契合,finally部分的代码无论任何情况最后都会被执行,所以我们可以使用finally来解决这个问题:
public class MyThread extends Thread{//ticketnum表示当前正在售卖第几张票//使用static将其变为共享的public static int ticketnum=0;//锁对象,用于加锁操作public static Lock lock=new ReentrantLock();;@Overridepublic void run() {//模拟卖票过程,总共100张票,售完为止while (true){//对卖票过程进行加锁lock.lock();//使用finally实现try {if(ticketnum<100) {ticketnum++;System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");}else{break;}}finally {lock.unlock();}//通常卖票是有时间间隔的,用sleep来模拟//由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catchtry {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}
运行结果为:
为了不用考虑使用lock手动加锁后是否最后都能解锁的问题,通常使用如下方式:
lock.lock();
try{需要锁住的代码
}finally{lock.unlock();
}
catch块根据实际情况可以加上,用于捕捉异常并处理。
Lock除了普通的lock方法获取锁外,还有其他的方式获取锁:
- trylock()
这种方式只是尝试获取锁,如果获取不到则不会等待,会立即返回获取锁的结果,成功获取返回true,获取失败则返回false;lock方法则是会先尝试获取锁,如果获取不到则陷入等待,直到成功获取锁。
使用格式为:
if(lock.trylock()){try{//要锁住的代码}catch(异常){//异常处理代码}finally{lock.unlock();}
}
- trylock(long time,Time.Unit unit)
这种方式也是尝试获取锁,但可以设置等待时间,要传入时间的值和单位;所谓等待时间,就是如果当前获取锁失败还可以等待多长时间,如果在这段时间内成功获取到了锁则返回true,反之返回false。
使用格式为:
if(lock.trylock(时间的值,时间的单位)){try{//要锁住的代码}catch(异常){//异常处理代码}finally{lock.unlock();}
}
- lockInterruptibly()
这个方法可以在获取锁时响应中断,使用时需要使用try-catch包围或者抛出异常。当线程获取不到锁时会进入等待状态,此时若通过线程实例调用interrupt方法可以中断等待过程,而lockInterruptibly()方法允许在获取不到锁时响应interrupt方法的执行,进而中断等待过程。interrupt方法只会中断阻塞过程中的线程,并不会中断正常运行的线程。
使用格式为:
//或者直接抛出异常
try {l.lockInterruptibly();} catch (InterruptedException e) {throw new RuntimeException(e);}
try{//要锁住的代码}catch(异常){//异常处理代码}finally{lock.unlock();}
使用Lock加锁和使用synchronized加锁有什么不同呢?
- synchronized是java的一个内置关键字,而Lock则是一个接口。
- synchronized是自动加锁,如果线程1已经获取到了锁,线程2再要获取这个锁时会自动陷入等待,并且整个过程不可见,无法判断当前是否成功获得了锁;而Lock可以通过trylock()方法尝试获取锁,该方法不会陷入等待并且会立即返回结果,可以通过返回值判断当前是否获取到了锁。
- synchronized会自动解锁,而Lock必须要手动释放锁,如果不释放则线程就不会停止运行。
- synchronized的锁是可重入、非公平的锁,并且获取不到锁后陷入的等待过程不可中断(因为加锁过程是自动的,我们无法控制);而Lock的锁是可重入的锁,但可以选择是否是非公平的锁,也可以使用lockInterruptibly方法响应中断。
- synchronized一般用于对少量代码加锁,Lock一般用于对大量代码加锁。少量代码一般所需的运行时间短,对应的加锁时间就短,当有其他线程也获取这个锁时等待时间就短,就没必要考虑等待过程不可中断的问题,反正等不了多长时间锁就释放了;而大量代码通常执行的时间较长,加锁时间也就越长,这意味着其他线程需要等待很长时间,此时如果想要中断等待过程只能用Lock。
6.死锁
死锁就是指,线程1加了A锁又准备加B锁,线程2加了B锁又准备加A锁,此时由于B锁已经被添加,线程1只能等待B锁释放,而线程2由于A锁也已经被添加,也在等待A锁释放,这样线程1、2因为要等待对方先释放锁导致一直无法解除自身已经添加的锁,造成死锁。所以在使用多线程时尽量不要嵌套加锁。