1、线程安全问题
线程安全问题产生的主要原因有两个:共享资源和多个线程共同操作共享数据。就是当多个线程同时操作同一个可共享的资源时导致出现的一些不必要的问题,此时就需要线程同步。
我们通过一个非常经典的案例卖票来演示线程安全问题(三个窗口总共卖100张票):
package com.thr; class Ticket implements Runnable{ //定义100张票(临界资源) private int ticket = 100; @Override public void run() { while (ticket > 0) { try { Thread.sleep(10);//让效果明显一点加个sleep } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "卖出了一张票,票号为:" + ticket ); ticket--; } } } public class RunnableDemo { public static void main(String[] args) { //创建一个ticket对象 Ticket ticket = new Ticket(); Thread t1 = new Thread(ticket,"窗口1"); Thread t2 = new Thread(ticket,"窗口2"); Thread t3 = new Thread(ticket,"窗口3"); t1.start(); t2.start(); t3.start(); } }
运行结果如下图:
从运行的结果可以发现:上面卖的票出现了问题:
- 卖的票出现了重复的票。
- 卖的票出现错误的票 0、-1。
其实,出现上面的原因都是由全局变量ticket引起的,因为只创建了一个Ticket实例对象,所以多个线程共享这一个全局变量ticket=100。假如窗口1和窗口2这两个线程,在某一时刻,窗口1和窗口2线程都读取到了ticket=100,那么可能会发生这种情况:窗口1打印100然后减减,然后窗口2也打印了100再减减(这是重票,错票同理)。此时这个就是线程安全问题,即多个线程同时访问一个共享数据(也称临界资源)时,会导致程序运行结果并不是想看到的结果。所以为了避免产生线程安全问题提供了三种解决方法:
- 同步代码块(synchronize)
- 同步方法
- 锁机制(Lock)
2、同步代码块
同步代码块的格式:
synchronized(锁对象) {
线程安全问题的代码
}
当在某个线程中执行这段代码块时,该线程会获取我们指定的对象的锁,从而使得其他线程无法同时访问该代码块。
在使用同步代码块时注意:
- 同步代码块的锁对象,可以是非null的任意对象。
- 必须保证多个线程使用的锁对象必须是同一个。
使用上面线程安全问题的例子举例:
class Ticket implements Runnable{ //定义100张票(临界资源) private int ticket = 100; private Object object = new Object(); @Override public void run() { synchronized (object) {//获取自定义对象的锁,可以是Java非NULL的所有对象 //synchronized (this):获取当前对象锁,此时的this为Ticket ticket = new Ticket();对象 //synchronized (Ticket.class):获取当前类对象锁,Class clazz=Ticket.class while (ticket > 0) { try { Thread.sleep(10);//让效果明显一点加个sleep } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "卖出了一张票,票号为:" + ticket); ticket--; } } } }
在main方法中的代码不变,运行之后的结果就不会再出现重票错票的情况了。
3、同步方法
同步方法的格式:
修饰符 synchronized 返回值类型 方法名称(参数列表) {
方法体
}
这种方式就是把操作共享资源的代码抽取出来,放到一个用synchronize关键字修饰的方法中。
还是以上面线程安全问题的例子举例:
class Ticket implements Runnable{ //定义100张票(临界资源) private int ticket = 100; @Override public void run() { sell(); }
//定义同步方法 public synchronized void sell(){ while (ticket > 0) { try { Thread.sleep(10);//让效果明显一点加个sleep } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "卖出了一张票,票号为:" + ticket); ticket--; } } }
运行结果同样不会出现问题,不过要注意的是此时的锁对象不再是任意对象了,此时的锁对象是this,也就是说谁调用了方法,那么this就是谁,而由于当前就创建了一个Ticket ticket = new Ticket();实例对象,所以此时的this就是ticket实例的锁对象。
注意:不能直接用 synchronized 来修饰 run() 方法,因为这样做就是第一个线程进来之后拿到锁对象,一直执行完所有操作才出来,其它线程一直等待第一个线程执行完,这样你创建的多个线程就没有任何意义了。
同步静态方法:
同步静态方法和同步方法很像,就是在同步方法前面加了个static关键字,添加之后的锁对象就不再使this了,而是类对象。我们在使用静态同步方法时,共享数据也要加上static,因为只有static成员才能访问static成员。
一般情况下,不使用static锁:因为JVM编译的时候,static是存到方法区,方法区是垃圾回收机制不会回收的。
最后总结synchronize关键字修饰不同地方的锁对象:
- 修饰代码块,指定加锁对象,对给定对象加锁,线程进入同步代码前要获得给定对象的锁,这个锁可以是实例锁,也可是类对象锁。
- 修饰实例方法,作用于当前实例加锁,线程进入同步代码前要获得当前实例的锁,即this锁。
- 修饰静态方法,作用于当前类对象加锁,线程进入同步代码前要获得当前类对象的锁,即 类.class锁。
同步的原理:当多个线程同时对一个共享数据进行操作时,只有一个线程能够拿到锁对象,因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程是无法获取该锁对象的,所以只能等待拿到锁对象的线程运行完释放过后,其它线程才能访问,这样便实现了线程对临界区的互斥访问,保证了共享数据安全。这也就是锁的竞争问题,也是死锁产生的条件。
4、死锁(DeadLock)
线程的同步虽然能够解决线程安全问题,但是它满足了互斥条件,而这是产生死锁的必要条件,所以是有可能出现死锁的。
就举一个生活中的例子:某天,2个人一起吃饭但是只有一双筷子(规定只有一双筷子才能吃饭)。就在某一时刻,一个人拿起了左边的筷子,另一个人拿起右边的筷子,此时2个人都同时占用一个资源,它们都在等待对方吃完把筷子拿过来,但是没有人放筷子都想吃饭呢,这样就一直僵持着,谁也无法吃饭,就形成了死锁。
线程死锁产生的原因:多个线程分别占用对方需要的同步资源不放弃,都在等对方放弃自己需要的同步资源,从而形成了死锁。
产生死锁的必要条件:
- 互斥条件:某资源只能被一个进程使用,其他进程请求该资源时,只能等待,知道资源使用完毕后释放资源。。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程--资源的环形链。
产生死锁的一个例子:
package com.thr; /** * @author Administrator * @date 2020-03-25 * @desc 死锁举例 */ public class DeadLockDemo { private static Object o1=new Object(); private static Object o2=new Object(); public static void main(String[] args) { //线程1 new Thread(new Runnable() { @Override public void run() { synchronized (o1){ //System.out.println("111"); try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o2){ System.out.println("222"); } } } }).start(); //线程2 new Thread(new Runnable() { @Override public void run() { synchronized (o2){ //System.out.println("333"); try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o1){ System.out.println("444"); } } } }).start(); } }
通过运行结果发现,死锁出现后,不会出现任何异常,不会输出任何提示,但程序也不会终止,所有线程都处于阻塞状态,无法继续。
上面程序分析:首先是线程1启动,先获取到o1锁,然后睡眠500毫秒,在睡眠过程中,线程2会启动,然后获取o2锁,睡眠500毫秒。当线程1睡眠结束后,需要获取o2锁才能继续执行完成,而此时的o2已经被线程2获取锁定了,线程2获取o1同理。所以此时线程1和线程2相互等待,都需要等待对方释放锁对象才能继续执行,从而产生死锁。
那么怎么来预防死锁的产生呢?
可以通过破坏死锁产生的4个必要条件来预防死锁,由于资源互斥是资源使用的固有特性是无法改变的,所以只需破坏其他3个即可。
- 破坏”请求与保持条件“:第一种方法静态分配即每个进程在开始执行时就申请他所需要的全部资源。第二种是动态分配即每个进程在申请所需要的资源时他本身不占用系统资源。
- 破坏“不可剥夺”条件:一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到 系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行。
- 破坏“循环等待”条件:采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。
5、锁机制(Lock)
在Java多线程中,可以使用synchronized关键字来实现线程之间的同步互斥。而在Java5中又新增了一个java.util.concurrent包来支持同步,使用其中的ReentrantLock类也同样能够达到同样的效果,而且比synchronize更加的灵活。ReentrantLock类是可重入、互斥的的,它实现了Lock接口。
其中有两个非常重要的方法:
- lock()方法:上锁
- unlock()方法:释放锁
使用ReentrantLock同步举例:
package com.thr; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class Ticket implements Runnable{ //定义100张票(临界资源) private static int ticket = 100; //创建一个锁对象 private Lock lock =new ReentrantLock(); @Override public void run() { try { //获取锁 lock.lock(); while (ticket > 0) { System.out.println(Thread.currentThread().getName() + "卖出了一张票,票号为:" + ticket); ticket--; } } catch (Exception e) { e.printStackTrace(); } finally { //释放锁 lock.unlock(); } } } public class RunnableDemo { public static void main(String[] args) { //创建一个ticket对象 Ticket ticket = new Ticket(); Thread t1 = new Thread(ticket,"窗口1"); Thread t2 = new Thread(ticket,"窗口2"); Thread t3 = new Thread(ticket,"窗口3"); t1.start(); t2.start(); t3.start(); } }
为了安全起见释放锁最好放在finally语句中。
synchronized与Lock的区别:
- synchronized当线程执行完毕或者抛出异常的话,会自动释放锁(相当于汽车中的自动挡)。
- Lock锁需要手动的启动同步(lock()),在结束同步是也需要手动的实现(unlock())(相当于汽车中的手动挡)。
优先使用顺序:
Lock>同步代码块>同步方法