JavaSE——多线程:线程的同步与死锁

同步问题:每一个线程对象轮番抢占共享资源带来的问题

1.同步问题的引出

需求:多个线程同时卖票

class MyThread implements Runnable {
    private int ticket = 10 ; // 一共十张票
    @Override
    public void run() {
        while(this.ticket>0) { // 还有票
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
// TODO Auto-generated catch block
                e.printStackTrace();
            } // 模拟网络延迟
            System.out.println(Thread.currentThread().getName()+",还有" +this.ticket --
                    +" 张票");
        }
    }
}
public class Test {
    public static void main(String[] args) {
        MyThread mt = new MyThread() ;
        new Thread(mt,"黄牛A").start();
        new Thread(mt,"黄牛B").start();
        new Thread(mt,"黄牛C").start();
    }
}
//黄牛C,还有10 张票
//黄牛B,还有10 张票
//黄牛A,还有9 张票
//黄牛A,还有8 张票
//黄牛C,还有8 张票
//黄牛B,还有7 张票
//黄牛B,还有6 张票
//黄牛C,还有6 张票
//黄牛A,还有6 张票
//黄牛B,还有5 张票
//黄牛A,还有5 张票
//黄牛C,还有4 张票
//黄牛A,还有3 张票
//黄牛C,还有3 张票
//黄牛B,还有3 张票
//黄牛A,还有2 张票
//黄牛B,还有1 张票
//黄牛C,还有2 张票

这个时候我们发现,票数并不同步

2.同步处理

同步处理:所谓的同步指的是所有的线程不是一起进入到方法中执行,而是按照顺序一个一个进来

2.1.synchronized处理同步问题

使用synchronized关键字处理有两种模式:

  • 同步代码块:如果要使用同步代码块必须设置一个要锁定的对象,所以一般可以锁定当前对象this
  • 同步方法:在方法上加synchronized关键字,表示此方法只有一个线程能进入

锁同步代码块示例如下:

class MyThread implements Runnable {
    private int ticket = 1000 ;
    @Override
    public void run() {
        //确保票能卖完
        for (int i = 0; i < 1000; i++) {
            // 在同一时刻,只允许一个线程进入代码块处理
            synchronized(this) { // 表示为程序逻辑上锁
                if(this.ticket>0) { // 还有票
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } // 模拟网络延迟
                    System.out.println(Thread.currentThread().getName()+",还有" +this.ticket -- +" 张票");
                }else {
                    break;
                }
            }
        }
    }
}

public class Test {
    public static void main(String[] args) {
        MyThread mt = new MyThread() ;
        Thread t1 = new Thread(mt,"黄牛A");
        Thread t2 = new Thread(mt,"黄牛B");
        Thread t3 = new Thread(mt,"黄牛C");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t3.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
        t3.start();
    }
}

如上是在方法里拦截的,也就是说进入到方法中的线程依然可能会有多个,锁同步方法示例如下:

class MyThread implements Runnable {
    private int ticket = 1000; // 一共十张票
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            this.sale();
        }
    }
    public synchronized void sale() {
        if (this.ticket > 0) { // 还有票
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } // 模拟网络延迟
            System.out.println(Thread.currentThread().getName() + ",还有" + this.ticket-- + " 张票");
        }
    }
}

public class Test {
    public static void main(String[] args) {
        MyThread mt = new MyThread();
        Thread t1 = new Thread(mt, "黄牛A");
        Thread t2 = new Thread(mt, "黄牛B");
        Thread t3 = new Thread(mt, "黄牛C");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t3.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
        t3.start();
    }
}

2.2.关于synchronized的额外说明

观察synchronized锁多对象:

class Sync {
    public synchronized void test() {
        System.out.println("test方法开始,当前线程为 "+Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("test方法结束,当前线程为 "+Thread.currentThread().getName());
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        Sync sync = new Sync() ;
        sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread() ;
            thread.start();
        }
    }
}
//test方法开始,当前线程为 Thread-0
//test方法开始,当前线程为 Thread-1
//test方法开始,当前线程为 Thread-2
//test方法结束,当前线程为 Thread-2
//test方法结束,当前线程为 Thread-1
//test方法结束,当前线程为 Thread-0

通过上述代码以及运行结果我们可以发现,没有看到synchronized起到作用,三个线程同时运行test()方法,实际上,synchronized(this)以及非static的synchronized方法,只能防止多个线程同时执行同一个对象的同步代码段。即synchronized锁住的是括号里的对象,而不是代码。对于非static的synchronized方法,锁的就是对象本身也就是this

  • 同步代码块

1.锁类的实例对象:synchronized(this){}
2.锁类对象(class对象):synchronized(类名称.class){} —> 全局锁
3.锁任意实例对象:
String lock = “”;
synchronized(lock){}

  • 同步方法

1.普通方法+synchronized:锁的是当前对象
2.静态方法+synchronized:锁的是类—>全局锁,效果等同于同步代码块的锁类对象

修改上述代码,锁同一个对象:

class Sync {
    public void test() {
        synchronized(this) {
            System.out.println("test方法开始,当前线程为 " +
                    Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("test方法结束,当前线程为 " +
                    Thread.currentThread().getName());
        }
    }
}
class MyThread extends Thread {
    private Sync sync ;
    public MyThread(Sync sync) {
        this.sync = sync ;
    }
    @Override
    public void run() {
        this.sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        Sync sync = new Sync() ;
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread(sync) ;
            thread.start();
        }
    }
}
//test方法开始,当前线程为 Thread-0
//test方法结束,当前线程为 Thread-0
//test方法开始,当前线程为 Thread-2
//test方法结束,当前线程为 Thread-2
//test方法开始,当前线程为 Thread-1
//test方法结束,当前线程为 Thread-1

全局锁:

class Sync {
    public void test() {
        synchronized(Sync.class) {
            System.out.println("test方法开始,当前线程为 " +
                    Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("test方法结束,当前线程为 " +
                    Thread.currentThread().getName());
        }
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        Sync sync = new Sync() ;
        sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread() ;
            thread.start();
        }
    }
}
//test方法开始,当前线程为 Thread-0
//test方法结束,当前线程为 Thread-0
//test方法开始,当前线程为 Thread-2
//test方法结束,当前线程为 Thread-2
//test方法开始,当前线程为 Thread-1
//test方法结束,当前线程为 Thread-1

2.3.synchronized实现原理——对象锁(monitor)机制

  • 执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令
  • 使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor
  • 当使用synchronized标记方法时,编译后的字节码中方法的访问标记多了一个ACC_SYNCHRONIZED,该标记表示,进入该方法时,JVM需要进行monitorenter操作,退出该方法时,无论是否正常退出,JVM需要进行monitorexit操作。所以monitorenter有一个monitorexit可能有多个
  • 这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的,对于实例方法来说,这两个操作对应的锁对象是 this,对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例
  • 当执行monitorenter时,如果目标锁对象的monitor计数器为0,表示此对象没有被任何其他对象所持有,此时JVM会将该锁对象的持有线程设置为当前线程,并将计数器+1;如果目标锁对象的计数器不为0,判断目标锁对象的持有持有线程是不是当前线程如果是再次将计数器+1(锁的可重入性),如果锁对象的持有线程不是当前线程,当前线程需要等待,直到持有线程释放锁
  • 当执行monitorexit指令时,JVM会将锁对象的计数器-1,当计数器减为0时,表示该锁对象已经被释放

2.4.JDk1.5提供的Lock锁

使用ReentrantLock进行同步处理:

class MyThread implements Runnable {
    private int ticket = 500;
    private Lock ticketLock = new ReentrantLock() ;
    @Override
    public void run() {
        for (int i = 0; i < 500; i++) {
            ticketLock.lock();
            try {
                if (this.ticket > 0) { // 还有票
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } // 模拟网络延迟
                    System.out.println(Thread.currentThread().getName() + ",还有" +
                            this.ticket-- + " 张票");
                }
            } finally {
                ticketLock.unlock();
            }
        }
    }
}
public class Test {
    public static void main(String[] args) {
        MyThread mt = new MyThread();
        Thread t1 = new Thread(mt, "黄牛A");
        Thread t2 = new Thread(mt, "黄牛B");
        Thread t3 = new Thread(mt, "黄牛C");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t3.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
        t3.start();
    }
}

在JDK1.5中,synchronized是性能低效的,因为这是一个重量级操作,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力,相比之下使用Java提供的Lock对象,性能更高一些。到了JDK1.6,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等,导致在JDK1.6上synchronize的性能并不比Lock差,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步

2.5.synchronized优化

Synchronized它最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方式肯定效率低下,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度(获取锁的速度)变快一点呢?

2.5.1.悲观锁和乐观锁

  • 悲观锁:假设每一次执行,同步代码块均会产生冲突,所以当线程获取锁成功会阻塞其他尝试获取该锁的线程(JDK1.6之前的内建锁)
  • 乐观锁:假设所有线程访问共享资源时不乐观锁状态下不会出现阻塞状态,也就是“无锁”状态

2.5.2.CAS操作

2.5.2.1.CAS概念

CAS:Compare and Swap, 比较交换机制,是乐观锁(无锁操作),无锁操作使用CS(比较交换)来判断是否出现冲突,出现冲突就重试当前操作直到不冲突为止

2.5.2.2.CAS操作过程

一般来讲,CAS交换过程分三个阶段(V,O,N):

  • V:内存中地址存放的实际值
  • O:预期值(旧值)
  • N:更新后的值

当执行CAS后,当V==O,即旧值和内存中实际值相等,表示上次修改后没有任何线程再次修改此值,因此可将N替换到内存中,如果V!=O,表示内存中的值已经被其他线程修改,所以无法将N替换,返回最新的V值,当多个线程使用CAS操作同一变量时,只有一个线程会成功,并成功更新变量值,其他线程会失败,失败线程会重新尝试或把线程阻塞(挂起)

2.5.2.3.元老级内建锁最主要的问题

元老级内建锁(Synchronized)最主要的问题:当存在线程竞争情况下会出现线程阻塞以及唤醒带来的性能问题,对应互斥同步(阻塞同步),效率降低,而CAS并不是武断地将线程挂起,而是会尝试若干次CAS操作,并非进行耗时的挂起与唤醒操作,因此非阻塞式同步

2.5.2.4.CAS的问题及解决方案

  • ABA问题

1.举例:ABA:比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化
2.解决方案:可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决,在JDK1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题

  • 自旋(CAS)会浪费大量的CPU资源(阻塞不会浪费)

1.问题阐述:与线程阻塞相比,自旋会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来
2.举例:我们可以用等红绿灯作为例子,Java 线程的阻塞相当于熄火停车,而自选状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如我们在同步代码块中只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更合适
2.解决方案:自适应自旋,根据以往自旋等待时能否获取到锁来动态调整自旋时间(循环尝试的数量),如果在上一次自旋时获取到锁,则此次自旋时间长一点,如果上一次自旋结束还没有获取到锁,此次自旋时间短一点

  • 公平性问题

自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁,然而,处于自旋状态的线程,则很有可能优先获得这把锁。内建锁无法实现公平机制,而lock体系可以实现公平锁

2.5.3.Java对象头

在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头。Java对象头里的Mark Word里默认的存放的对象的Hashcode、分代年龄和锁标记位

2.5.4.JDK1.6之后对内建锁做的优化(新增偏向、轻量级锁)

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级

2.5.4.1.偏向锁

  • 偏向锁:最乐观的锁,从始至终只有一个线程请求一把锁
  • 偏向锁的获取:当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录中记录存储偏向锁的线程ID,以后该线程再次进入同步块时,不需要 CAS来加锁和解锁,只需简单测试一下对象头的mark word中偏向线程ID是否是当前线程的ID,如果成功,表示线程已经获取到锁直接进入代码块运行。如果测试失败(不是当前线程ID),再测试一下Mark Word中偏向锁的标 识是否设置成1(表示当前是偏向锁),如果不是1,将偏向锁字段设置为1(采用CAS操作),并且更新自己的线程ID到mark word字段中;如果为1,表示此时偏向锁已经被别的线程获取,则次线程需要不断尝试使用CAS获取偏向锁,或者将偏向锁撤销,升级为轻量级锁,一般情况下后者(升级)概率较大
  • 偏向锁的撤销:偏向锁使用一种等待竞争出现才释放锁的机制,当有其他线程尝试竞争偏向锁时,持有偏向锁的线程才会撤销偏向锁

注意:偏向锁的撤销,需要等待线程进入全局安全点(在这个时间点上当前线程在CPU上没有执行任何有用字节码):它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

  • 关闭偏向锁

偏向锁在JDK6之后是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟

  • -XX:BiasedLockingStartupDelay=0,将延迟关闭,JVM一启动就激活偏向锁
  • -XX:-UseBiasedLocking=false,关闭偏向锁,程序默认进入轻量级锁

2.5.4.2.轻量级锁

  • 轻量级锁:多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争,针对此种情况,JVM采用轻量级锁来避免线程的阻塞及唤醒
  • 加锁:线程在执行同步代码块之前,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word字段直接复制到此空间中,官方称为Displaced Mark Word,然后线程尝试使用CAS将对象头的mark word替换为指向锁记录的指针(指向当前线程),如果成功,表示获取到轻量级锁,如果失败,表示其他线程竞争轻量级锁,当前线程便采用自旋来不断尝试获取锁
  • 释放:解锁时会使用CAS将复制的mark word替换回对象头,如果成功,表示没有竞争发生,正常解锁,如果失败,表示当前锁所存在竞争,进一步膨胀为重量级锁

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争

2.5.4.3.重量级锁

  • 重量级锁是JVM中最为基础的锁实现,针对的是多个线程同一时刻竞争同一把锁的情况,在这种状态下,JVM虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
  • Java线程的阻塞以及唤醒,都是依靠操作系统来完成的
  • 为了尽量避免昂贵的线程阻塞、唤醒操作,JVM会在线程进入阻塞状态之前,以及被唤醒之后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放,如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁

重量级锁是JVM中最为基础的锁实现,针对的是多个线程同一时刻竞争同一把锁的情况;轻量级锁采用CAS操作,将锁对象的标记字段替换为指向线程的指针,存储着锁对象原本的标记字段针对的是多个线程在不同时间段申请同一把锁的情况;偏向锁只会在第一次请求时采用CAS操作,在锁对象的mark_word字段中记录下当前线程ID,此后运行中持有偏向锁的线程不再有加锁过程,针对的锁仅会被同一线程持有

2.5.5.其他优化

  • 锁粗化:将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁
  • 锁消除:即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁

2.6.死锁

死锁产生原因:对共享资源的上锁成环
避免死锁:银行家算法

猜你喜欢

转载自blog.csdn.net/LiLiLiLaLa/article/details/94341201