多线程之同步问题、synchronized的优化和死锁问题

一、同步问题

1.管程

管程:在功能上和信号量即PV操作类似,属于一种进程同步互斥工具。但是管程具有与信号量及PV操作不同的属性。管程是由局部于自己的若干公共变量及其说明和所有访问这些公共变量的过程所组成的软件模块。

Java内存模型:描述多线程的逻辑结构 JMM

线程工作内存(线程私有,不同线程间相互隔离)

所有变量的读写必须在工作内存中进行,使用的变量均是从主内存中拷贝的副本。

主内存:所有变量(共享变量-成员变量、静态变量、数组)必须在主内存中存放

原子性:一个或一组操作要么同时发生,要么都不发生。

基本类型的读写属于原子性操作。若需要更大范围的原子性,需要管程(lock synchronized)来协助.

可见性:一个线程对变量的修改,对于其他线程而言是立即得知的。final synchorinzed volatile

有序性:happens-before原则

同步问题产生的原因:原子性、可见性和有序性没有同时满足。

2.同步

同步:在某一时刻只能有一个线程访问。

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

同步问题产生的原因:多个线程同一时刻访问共享资源(临界区)。

2.不同步问题

package com.xunpu.b;

public class Priority {
    public static void main(String[] args) throws InterruptedException {
        A a=new A();
        Thread thread1=new Thread(a,"A");
        Thread thread2=new Thread(a,"B");
        Thread thread3=new Thread(a,"C");
        thread1.start();
        thread2.start();
        thread3.start();
    }
}
class A implements Runnable{
    private static int ticket=10;
    public void run(){
        try {
            //模拟网络延迟
            Thread.sleep(2000);
            while(this.ticket>0){
                this.ticket--;
                System.out.println(Thread.currentThread().getName()+"还有"+this.ticket+"张票");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

结果可能出现负数,这种操作我们称为不同步操作。不同步带来的唯一好处是处理速度快。(多个线程并发执行)

3.不同步问题的解决方法

使用内建锁(synchronized)

同步代码块(在方法里面拦截,进入方法的线程仍然可能有多个)
	1)synchronized(this){}  this是Runnable对象,和线程对象没有关系。
	2)synchronized(类名称.class) 修饰的是类的反射对象,全局锁。(锁的是同步代码块)
	3)synchronized(任意对象)   任意对象都有markword字段
同步方法(确保进入方法的只有一个线程)
	1)修饰普通方法(相当于锁的是该类的实例对象)
	2)修饰静态方法(相当于锁的是类对象,全局锁)
public class Priority {
    public static void main(String[] args) throws InterruptedException {
       A a=new A();
       Thread thread1=new Thread(a,"A");
       Thread thread2=new Thread(a,"B");
       Thread thread3=new Thread(a,"C");
       thread1.start();
       thread2.start();
       thread3.start();
    }
}
class A implements Runnable{
    private static int ticket=10;
    public void run(){
        try {
            Thread.sleep(200);
        while(this.ticket>0) {
           //   synchronized (this) {
                synchronized (A.class){               
                    if(this.ticket>0) {//双重检查
                    this.ticket--;
                    System.out.println(Thread.currentThread().getName() + "还有" + this.ticket + "张票");
                }
            }
        }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

synchronized的实现原理:

对象锁(monitor)机制

Java中所有类的对象都有对象监视器(Monitor),获取一个对象的锁实际上就是获取该对象的Monitor。

当一个线程尝试获取对象Monitor时

1)若此时Monitor值为0,表示此对象Monitor未被任何线程持有,当前线程进入同步块,并且将Monitor的持有线程置为当前线程,Monitor值+1.

2)若此时Monitor值不为0,并且持有线程不是当前线程,当前线程等待Monitor值减为0.

3)若此时Monitor值不为0,并且持有线程为当前线程,那么当前线程再次进入同步代码块,Monitor值再次+1(可重入锁)

同步代码块的实现?

在进入同步代码块之前,JVM调用monitorenter,尝试获取锁的监视器(monitor对象)。如果获取成功,计数器加1;monitor要有一个记录当前获取锁的线程Id,当同步代码块执行完后,monitor执行moniterexit指令,计数器减1。当在任何时刻,锁对象的monitor的计数器值为0,表示无锁状态。如果在同一时刻,计数器值不为0,当前持有锁的线程再次进入同步代码块,尝试获取monitor,值直接再加1;如果值不为0,要看线程id和当前线程持有的id值是否一致,如果一致,再加1(可重入锁);不一致需要等待,直到值为0时,表示锁被释放,才可以继续竞争锁。

同步方法?

进入方法后,将方法做标记ACC_SYNCHRONIZED,表示在进入该方法前,JVM会先调用monitorenter,重复上述过程。在退出时,不管是正常退出,还是异常中断,JVM都会执行monitorexit操作。

练习:
class Sync{
    public synchronized void testA(){
        //线程1
    }

    public synchronized void testB(){
        //线程2
    }
}

/**
 * 前提:线程1和线程2共享一个sync对象
 * 假如此时线程1进入testA(),说明获取了当前sync的对象锁,并且计数器+1,此时值不为0,线程2不能进入testB()。
 * 因为线程1和线程2锁的是同一个sync对象,也就是它们要拿同一个sync的monitor对象,由于线程1已经获取到了monitor对象并且值加1.
 * 当线程2再次进入testB(),尝试获取monitor对象,此时值不为0,并且持有的线程也不是线程2,而是线程1,因此线程2需要等待。
 * 线程1可以进入testB()?
 * 可以
 * 原因:虽然此时计数器值不为0,但是此时持有线程是当前线程,所以当再次进入同步代码块或同步方法时,直接将计数器值+1,值变为2.
 * 当线程1执行完这两个方法时,计数器值-1-1,变为0,此时释放锁。
 */

3.同步处理

上述synchronized修饰同步代码块或同步方法其实就是同步处理。下面介绍同步处理的含义和特点:

含义

(1)同步指所有的线程不是一起进入到方法中执行,而是按照顺序依次执行
(2)用synchronized处理同步问题:使用同步代码块   使用同步方法
同步代码块:必须设置一个要锁定的对象,这个锁定的对象就是要竞争的资源。一般锁定当前对象this。同步方法保证进入该方法的只有一个线程。
(3)线程优先级较高的更有可能竞争到资源,优先执行。

特点:

(1)如果一个同步代码块和一个非同步代码块操作共享资源,这仍然会造成对共享资源的竞争。
    (因为当一个线程执行同步代码块时,其它线程仍然可以执行非同步代码块。)
(2)每个对象都有唯一的一把同步锁。
(3)进入同步代码块的线程也可以执行到Thread.sleep()或者Thread.yield()。此时它并没有释放锁,只是把运行机会(即CPU)让给了其它线程。
(4)synchronized声明不会被继承。若一个用synchronized修饰的方法被子类覆盖,则子类中这个方法不会保持同步,除非子类也是用synchronized修饰。

二.synchronized的优化

1.悲观锁和乐观锁

使用锁时,线程获取锁是一种悲观锁策略。

悲观锁:JDK1.6之前synchronized的实现。每次线程执行同步代码块时,都认为有其它线程与自己竞争。

乐观锁:任何时刻,不存在竞争。

2.CAS(Compare and Swap)

乐观锁的一种,也叫做无锁操作。它假设所有线程访问共享资源(也叫临界区)的时候不会发生冲突无锁操作就是使用CAS来鉴别线程是否出现冲突,出现冲突就继续重试当前操作直到没有冲突为止。

2.1 CAS的操作过程:

V:内存中实际存在的值   O:期望值    N:更新后的值

1)当V==O时,将新值N赋值给V。(因为V=O,说明旧值没有被其它线程修改过,即旧值O目前就是最新的值。)

2)当V!=O时,不能将N赋值给V,直接返回V。(因为V!=O,这说明旧值已经被其它线程所修改,不能再修改值了。)

2.2 CAS与元老级的synchronized(未优化前)的区别:

synchronized的主要问题:在存在线程竞争的情况下,会出现线程阻塞和唤醒锁带来的性能问题,这是一种互斥同步(阻塞同步);而CAS并不是武断地将线程挂起,当CAS操作失败后会进行一定的尝试(自旋),而不是进行耗时的唤醒挂起的操作,因此也叫做非阻塞同步。

2.3 CAS带来的问题:

1)ABA问题:(由于CAS更新造成的结果)  A->B->A    
线程1:A
线程2:B(线程2将A值修改为B)
线程3:A(线程3将B值修改为A)
线程1再次进入同步代码块,发现值没有修改,将A改为N,不能改!!(因为值已经发生了变化)
 解决:添加版本号(eg:MySQL的行锁就采用这种机制)eg:1A->2B->3A。
在JDK1.5后的atomic包中提供了AtomicStampedReference就是这样解决的。

2)自旋浪费处理器资源    

原因:当前线程仍然处于运行状态,只不过跑的是无用指令。期望在运行无用指令的过程中,能够获取锁。

解决:JVM提供的自适应自旋(重量级锁的优化)。(阻塞不会浪费处理器资源)
根据上一次的自旋能否获取锁来调整下一次的自旋时间。
自旋策略:如果上一次获得锁了,这次自旋时间就长一些;如果上次自旋时间内没有获得锁,这次的等待时间就短一些。

3)公平性
处于自旋状态的线程比处于阻塞状态的线程更容易获取锁。
内键锁无法实现公平机制,但是lock体系可以实现公平锁

3.Java对象头 

对象的锁:对对象的一个标志,这个标志存放在Java对象的对象头里。Java对象的Mark Word里默认的存放的是对象的Hashcode,分代年龄和锁标志位。

Mark Word:
0 01:无锁
1 01:偏向锁
00:轻量级锁
10:重量级锁
11:对象标记,标记是否要被回收。

锁只能升级不能降级!!!

目的:为了提高获得锁和释放锁的效率。

4.偏向锁

从始至终只有一个线程请求一把锁。(四种锁中最乐观的锁

偏向锁的获取过程:
当一个线程访问同步块并获取锁时,会在对象头和栈帧的记录里存储偏向线程的id,以后在该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存放有当前的线程id。如果测试成功,说明线程已经获取到了锁;测试失败,则需要在测试一下Mark Word偏向锁的标识是否为1(表示当前锁是偏向锁);如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:等到竞争出现才释放锁机制。当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
何时升级?当不同时刻有不同的线程尝试获取锁时,偏向锁会膨胀为轻量级锁。当有线程竞争时,可能升级为轻量级锁。(概率较大)。偏向锁头部Epoch字段值:表示此对象偏向锁的撤销次数。默认撤销40次以上,表示该对象不再适用于偏向锁,当下次线程再次获取此对象时直接变为轻量级锁。高于30次,变为轻量级锁的概率较高;20次以下,偏向偏向锁的概率较高。
只有一次CAS过程,出现在第一次加锁时。

5.轻量级锁

多个线程在不同时刻竞争同一把锁。JVM采用轻量级锁,避免线程的阻塞和唤醒。

加锁:

线程在执行同步代码块前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获取到锁;如果失败,表示其它线程竞争锁,当前线程尝试使用自旋来获取锁。

何时升级为重量级锁?当同一时刻有多个线程尝试获取锁时,轻量级锁会升级为重量级锁。
轻量级锁和偏向锁的相同点:偏向锁和轻量级锁都没有阻塞过程,都有CAS操作。
区别:偏向锁只在第一次地时候进行CAS过程,而轻量级锁不停地进行CAS操作。

6.重量级锁

多个线程在同一时刻竞争同一把锁。

总结:

Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量级锁和偏向锁三种。

重量级锁:会阻塞、唤醒请求加锁的线程。JVM采用了自适应自旋,避免线程在面对非常小的synchronized代码块时,仍会被阻塞、唤醒的情况。针对多个线程在同一时刻请求同一把锁的情况。

轻量级锁:采用不断地CAS操作。将锁对象的标记字段替换为一个指针,指向当前线程上的一块空间,存储着锁对象原来的标记字段。针对多个线程在不同时刻请求同一把锁的情况。

偏向锁:只会在第一次请求时采用CAS操作。在锁对象的标记字段中记录下当前线程的地址,在之后的执行过程中,持有该对象锁的线程的加锁操作将直接返回。针对锁仅会被一个线程持有的情况。

7.其它优化

锁粗化:

当有多个连续加锁、解锁的过程,将多个加锁、解锁的过程合并为一次范围大的加锁解锁的操作,减少因为加减锁带来的开销。

eg:StringBuffer的append()。append()调用一次,就会进行加锁、解锁一次,把多个append()放到一次里面进行加锁一次。

锁消除:

在不会出现锁竞争的场景,会将线程安全的集合或类中的锁取消。

如:append()语句位于不同的方法中,不存在竞争,因此会将锁消除。(或将StringBuffer换为StringBuilder使用)

public void run() {
    StringBuffer sv=new StringBuffer();//三个线程拥有三个变量,不存在竞争,直接锁消除。
    }
如果此时有三个线程都启动,它们拥有自己的sv变量,不存在竞争,锁消除。

三、死锁问题

1.定义

当一个线程等待由另一个线程持有的锁,而后者正在等待已被第一个线程持有的锁时,就会发生死锁。Java虚拟机不监测也不试图避免这种情况。

操作系统中这样定义死锁:如果一组进程中的每一个进程都在等待仅有该组进程中其它进程才能引发的事件,那么该组进程是死锁的。

2.死锁产生的原因

死锁产生的四个必要条件:互斥条件、请求和保持条件、不可抢占条件、循环等待条件。

具体原因:

1)互斥条件:共享资源只能被一个线程占用。

2)占有且等待:线程A已经取得共享资源X,在等待获取资源Y时,不释放X。

3)不可抢占条件:线程A已经获取X之后,其它线程不能强行抢占X。

4)循环等待条件:线程A占用X,线程B占用Y,A等待Y,B等待X。

3.解决方法

只要破坏其中任何一个条件,就可以解决。可以使用Lock体系独有的方法。见下一篇Lock体系

4.观察死锁

package com.xunpu.b;

class Tea{
    private String tea="茶叶";
    public String getTea(){
        return this.tea;
    }
}
class Water{
    private String water="水";
    public String getWater(){
        return this.water;
    }
}


public class Demo1{
    private static Tea tea=new Tea();
    private static Water water=new Water();

    public static void main(String[] args) {
        new Demo1().dead();
    }

    private void dead(){

        Thread thread1=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (tea){
                    System.out.println(Thread.currentThread().getName()+" 我有茶叶");
                    try {
                        Thread.sleep(1000);//模拟等待时间,效果较明显!
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (water){
                        System.out.println(Thread.currentThread().getName()+" 我需要你的水");
                    }
                }
            }
        },"茶叶线程");

        Thread thread2=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (water){
                    System.out.println(Thread.currentThread().getName()+" 我有水");
                    synchronized (tea){
                        System.out.println(Thread.currentThread().getName()+" 我需要你的茶叶");
                    }
                }
            }
        },"水线程");
        thread1.start();
        thread2.start();
    }
}

死锁一旦出现之后,整个程序将中断执行。过多的同步会造成死锁,对于资源上的上锁一定不要变成“环”!

你可能感兴趣的:(Java)