对于之前买票的练习,又出现多个10张票的情况,这里对这一现象进行分析
对于代码
for (int i = 1; i <= 100 ; i++) {
if (ticketNum>0){
System.out.println("我在"+this.getName()+"买到了第"+ ticketNum-- +"张票");
}
}
对于ticketNum-- ,其实分为两步
1)得到ticketNum
2)ticketNum-1
那么,如果在线程执行完1)时,资源就被其他线程抢走,就会出现如下情况
线程1:我在 窗口1 买到了第 10 张票,还未进行--操作,资源被线程2抢走
线程2:我在 窗口3 买到了第 10 张票,还未进行--操作,资源被线程3抢走
线程3:我在 窗口2 买到了第 10 张票,进行--操作,ticketNum=9
线程1:抢到了资源,进行--操作,ticketNum=8
线程2:抢到了资源,进行--操作,ticketNum=7
那么此时就会出现多个10张票,同理,如果在最后一张票的抢票线程执行过程中,资源被抢走,那么就会出现-1、-2的票数。
以上问题都是线程安全引起的问题,原因是多个线程在争抢资源时,都对共享的资源进行操作,而导致共享的资源出现错误。
解决这个问题需要引入“锁”---》同步监视器
一、同步代码块:synchronized关键字(同步监视器){}
必须多个线程使用同一把锁!锁必须是引用数据类型
1.synchronized (this){}:()内是锁住的内容,this指代当前对象
对于只创建一个线程对象(实现Runnable接口),this指代当前这个对象,也就是当前的对象被锁住,即使创建了多个线程,只要t1线程对象正在执行,也就是bt已经被锁住,那么t2线程对象想运行时也会发现bt被锁住,无法抢夺资源
for (int i = 1; i <= 100; i++) {
synchronized (this) {//this就是这个”锁“
//只放入具有安全隐患的代码,锁住不需要锁的代码会造成效率变低。
//只有synchronized{}中的代码执行完毕后才会释放资源,给其他线程进行争抢
if (ticketNum > 0) {
System.out.println("我在" + Thread.currentThread().getName() + "买到了第" + ticketNum-- + "张火车票");
}
}
}
2.synchronized (BuyTicketThread.class){}
对于创建多个线程对象(继承Thread类),使用这种方式,因为BuyTicketThread.class:字节码信息,是唯一的,也就是多个线程只认这同一把锁
for (int i = 1; i <= 100 ; i++) {
synchronized (BuyTicketThread.class){
//BuyTicketThread.class:字节码信息,是唯一的,也就是多个线程只认这同一把锁
if (ticketNum>0){
System.out.println("我在"+this.getName()+"买到了第"+ ticketNum-- +"张票");
}
}
}
3.同步监视器特点
1)必须是引用数据类型,最好使用final修饰(不可以改变)
2)也可以创建一个没有任何意义的唯一的锁(例如static Objecto=new Object),但一般使用共享资源:字节码信息
3)尽量不使用String和包装类Integer左同步监视器
4.同步代码块的执行过程
1)线程1执行到同步代码块,发现同步监视器(锁)处于open状态,于是close锁后执行代码块中的代码
2)线程1执行过程中,发生了线程切换,线程1失去CPU,但是没有开锁
3)即使线程2抢到了CPU,执行到了同步代码块处,发现锁处于close状态,无法执行代码块中的代码,于是线程2进入阻塞状态
4)线程1再次获取CPU,执行完代码块中的代码后,释放锁
5)线程2获取CPU,发现锁处于open状态,于是close锁,由阻塞状态进入就绪再进入运行状态,执行代码块中的代码
- 同步代码块中可以发生CPU的切换,但是后续的线程即使抢到了CPU,也无法执行代码(因为锁没有被释放)
- 对于多个代码块使用同一个同步监视器(锁),锁住其中一个代码块的同时,也锁住了其它使用该锁的代码块,其它线程无法执行这些代码块。而使用其它锁的代码块不受影响
对于下面的代码,a、b方法使用的是同一把锁o,那么当线程1执行a(),锁住了o后,即使线程2获取了CPU可以执行b(),但是由于o被锁住,b()也是无法执行的,只有o被释放才能执行b()。
二、同步方法
1.synchronized
在方法的定义中加上synchronized,同样是只能解决实现Runnable接口的代码,因为只有一个对象,锁住的是同一个方法。
@Override
public void run() {
for (int i = 1; i <= 100; i++) {
//调用if语句的方法
buyTicket();
}
}
//将具有安全隐患的if语句放入新建的方法中,在方法定义中加入synchronized
public synchronized void buyTicket(){
if (ticketNum >0){
System.out.println("我在"+Thread.currentThread().getName()+"买到了第"+ ticketNum-- +"张火车票");
}
}
2.static synchronized
在方法的定义中加上static synchronized,可以解决继承Thread类的代码,因为有多个对象,需要将方法设置为唯一的方法,于是设置为类方法,这样就可以 保证锁住的是同一个方法
static修饰后,源码不能使用
@Override
public void run() {
//2.100个人
for (int i = 1; i <= 100 ; i++) {
buyTicket();
}
}
public static synchronized void buyTicket(){
if (ticketNum>0){
System.out.println("我在"+Thread.currentThread().getName()+"买到了第"+ ticketNum-- +"张票");
}
}
3.同步方法总结
1)run()方法不能定义为同步方法
2)非静态同步方法的同步监视器是this,静态同步方法的同步监视器是 类名.class字节码信息对象
3)同步代码块的效率高于同步方法(因为同步方法中可能有很多其他这个方法的代码,会全部被锁住,而同步代码块只会锁住有安全隐患的代码)
4)同步方法的锁是this,一旦锁住一个方法,也就锁住了所有的同步方法,而同步方法块只会锁住使用同一个同步监视器的代码块。
例如上面,当线程1调到这个类下面的a()方法时,锁住了this,由于a()是同步方法,那么线程2来调b()方法也是调不到的,因为这个类的对象this被锁住了。
三、Lock锁(JDK1.5之后新增)
1.synchronized和Lock的区别
1)synchronized是Java中的关键字,是虚拟机级别的,依靠JVM识别;Lock锁是API级别,提供了相应的接口和实现类,更灵活,优于synchronized
2)synchronized是隐式锁,Lock是显式锁(需要手动开启关闭)
3)synchronized有代码块和方法锁,Lock只有代码块锁
4)使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,扩展性更高(提供更多子类)
一般使用顺序:Lock > 同步代码块 > 同步方法
2.使用方法
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BuyTicketThread extends Thread{
public BuyTicketThread(String name) {
super(name);
}
static int ticketNum=10;
//Lock是接口,不能直接创建对象,需要使用它的实现类创建对象
//1.新建锁
Lock lock = new ReentrantLock();
@Override
public void run() {
//2.100个人
for (int i = 1; i <= 100 ; i++) {
//2.在if前打开锁
lock.lock();
//由于if可能会执行异常,就不会执行后面的关闭锁的语句,那么其它线程永远无法再次打开锁
//所以这里需要捕捉异常,将关闭锁的代码放入finally中保证一定会执行
try {
if (ticketNum>0){ //里面不能再使用this.getName()
System.out.println("我在"+Thread.currentThread().getName()+"买到了第"+ ticketNum-- +"张票");
}
}catch (Exception e)
{
e.printStackTrace();
}finally {
//3.if执行完毕后关闭锁
lock.unlock();
}
}
}
}
四、线程同步的缺点
1)线程同步-->线程安全,效率低(例如锁住this,所有类的对象可调用的同步方法都会被锁住),线程不安全,效率高
2)可能造成死锁:不同线程分别占用对方需要的同步资源不放,都在等待对方先放开自己需要的同步资源,就造成了死锁。死锁不会出现异常和提示,只会线程都处于阻塞状态
死锁代码示例
public class TestDeadLock implements Runnable{
public int flag=1;
static Object o1 = new Object(),o2=new Object();
@Override
public void run() {
System.out.println(flag);
//当flag=1时锁住o1
if (flag==1){
synchronized (o1){
//有异常,捕捉
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//真正需要执行的代码是下面一行
synchronized (o2){
System.out.println(2);
}
}
}
//当flag=0时锁住o2
if (flag==0){
synchronized (o2){
//有异常,捕捉
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//真正需要执行的代码是下面一行
synchronized (o1){
System.out.println(1);
}
}
}
}
public static void main(String[] args) {
//创建两个线程类的实例
TestDeadLock td1 = new TestDeadLock();
TestDeadLock td2 = new TestDeadLock();
td1.flag=1;
td2.flag=0;
Thread t1 = new Thread(td1);
Thread t2 = new Thread(td2);
t1.start();
t2.start();
}
}
代码解析:
1)创建的两个线程t1和t2,由于td1=1,td2=0,也就是说当线程启动后,t1走到if(flag==1)中,t2走到if(flag==0)中
2)t1将o1锁住后执行代码:锁住o2,同时t2将o2锁住后执行代码:锁住o1
3)但是由于o2已经被锁住,所以t1无法执行代码:锁住o2;同时o1也已经被锁住,所以t2无法执行代码:锁住o1
4)t1在等待t2解锁o2,t2在等待t1解锁o1,双方都进入阻塞状态,造成死锁
五、各种类型的锁
在单线程环境中,由于不存在资源竞争,所以不需要锁。但在多线程环境中由于存在资源共享和竞争,为了合理的分配资源及公平的使用资源,所以需要锁。在计算机系统中,多线程需要多多核处理器的支持,每个核以时间片的方式进行资源调度,一旦线程获取到时间片,就开始执行代码逻辑,当线程没有获取时间片,就暂停执行代码逻辑。
1)乐观锁:又称为“无锁”,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_conditio 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁。
2)悲观锁:就是我们常说的锁。悲观的认为每次去拿数据都认为别人会修改,所以在每次拿数据的时候都会上锁,这样别人想拿数据时就会阻塞直到拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中synchronized 和 ReentrantLock 等独占锁就是悲观锁的思想。
3)同步锁:
1.CAS的原理
CAS的全称是比较并交换(compare and swap),CAS中有3个值:
比较并交换的过程为:判断V是否等于E,如果等于,将V的值设为N;如果不等于,说明已经有其他线程更新了V,则当前线程放弃更新,什么都不做。E本质上是旧值。
举例如下:
1)存在一个多线程共享的变量i,i=5,现在线程A需要设置i为6,
2)首先将i和5对比,若i=5,说明没有其他线程修改过i,那么线程A就更新i为6,
3)若i≠5,说明有其他线程修改过i(例如修改为2),那么线程A什么也不做。i仍等于2
由于CAS是一种原子操作,它是⼀条CPU的原子指令,从CPU层面保证了原子性。当多个线程同时使⽤CAS操作⼀个变量时,只有⼀个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
对于native方法,Java不负责具体实现,交由底层JVM使用c或c++去实现。