多线程与高并发(三)synchronized关键字

2019-08-09 00:00:00 关键字 多线程 并发

上一篇中学习了线程安全相关的知识,知道了线程安全问题主要来自JMM的设计,集中在主内存和线程的工作内存而导致的内存可见性问题,及重排序导致的问题。上一篇也提到共享数据会出现可见性和竞争现象,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。而共享数据如何处理,一个很简单的想法就是依次去读写共享变量,这样就能保证读写的数据是最新的,就不会出现数据安全性问题,java中我们使用synchronized关键字去做让每个线程依次排队操作共享变量的功能。很明显这样做效率不高,但是这是基础。

一、使用synchronized

我们从使用开始学习,synchronized是一个关键字,在我们需要进行同步处理的地方加上synchronized即可。

分类具体分类被锁对象伪代码
方法实例方法类的实例对象public synchronized void method(){}
静态方法类对象public static synchronized void method(){}
代码块实例对象类的实例对象synchronized(this){}
class对象类对象synchronized(Demo.class){}
任意实例对象的Object实例对象ObjectString lock=””;synchronized(lock){}

synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里的需要注意的是:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系

1.1 同步方法

先看下下面这段代码:

public class SynchronizedDemo1 {
private int count;

public void countAdd() {
    for (int i = 0; i < 5; i++) {
        try {
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public static void main(String[] args) {
    SynchronizedDemo1 demo1 = new SynchronizedDemo1();
    new Thread(new Runnable() {
        @Override
        public void run() {
            demo1.countAdd();
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            demo1.countAdd();
        }
    }).start();
}
}

这是未使用synchronized时,运行结果:

《多线程与高并发(三)synchronized关键字》

1.1.1 普通方法

 这里可以看出,两个线程是同时运行的,而且出现了一定的线程安全问题,共享变量的数据存在问题,这个时候我们加上关键字synchronized,只要改一行代码。

public synchronized void countAdd()

运行结果如下:

《多线程与高并发(三)synchronized关键字》

这里可以看到Thread-1增加数据是等Thread-0增加完数据之后才进行的,说明Thread-0和Thread-1是顺序执行countAdd方法的。

其实我们要理解这里的原理,一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized方法,所以上面说被锁的是这个实例对象,在想深入一点,我们再添加一个不加锁的方法,会怎么样:

public void print() {
    System.out.println(Thread.currentThread().getName() + "的打印方法:" + count);
}

然后改下主方法:

public static void main(String[] args) {
        SynchronizedDemo1 demo1 = new SynchronizedDemo1();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.print();
                demo1.countAdd();
            }
        }).start();
    }

《多线程与高并发(三)synchronized关键字》

很明显这里Thread-1的打印方法不需要等待Thread-0的增加方法结束,这个我们要理解非synchronized方法是不需要获取到对象锁就可以执行的。

还有一点,这里锁住的是实例对象,如果我们生成了多个实例,那他们之间是不受影响的,也就是多个实例多个锁。这里可能需要自己理解理解,并不是那么简单。

1.1.2 静态方法

上面的方法主要是针对的普通方法,普通方法锁住的是实例对象,我们知道静态方法是属于类的,也就是说静态方法锁定的是这个类的所有对象,即不管创建多少个实例,都需要等待锁释放:

public class SynchronizedDemo2 {
    private static int count;

    public static synchronized void countAdd() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo2 demo1 = new SynchronizedDemo2();
        SynchronizedDemo2 demo2 = new SynchronizedDemo2();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo2.countAdd();

            }
        }).start();
    }
}

结果:

《多线程与高并发(三)synchronized关键字》

这里可以看到虽然是创建了两个实例,但是他们之间还是用了同一把锁。

1.2 同步代码块

synchronized不仅可以作用某个方法,也可以作用代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。

public class SynchronizedDemo3 {

    private int count;

    public void countAdd() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo3 demo1 = new SynchronizedDemo3();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();

            }
        }).start();
    }
}

运行结果与之前相同,这里使用的是this,说明锁住的是实例对象,结合前面的理解,如果创建多个实例也是不会锁住的。

但是如果将所 synchronized (this)改成synchronized (SynchronizedDemo3.class),那锁住的就是类,那即使创建多个实例,也依然会被锁住。

二、原理分析

synchronized的使用最重要的是理解锁住的对象是啥,下面来分析下底层的实现原理。

2.1 对象锁(monitor)机制

先看一段简单的代码:

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
        }
        method();
    }

    private synchronized static void method() {
    }
}

代码中有同步代码,锁住类对象,编译完成后,切至SynchronizedDemo.class目录中,执行javap -v SynchronizedDemo.class查看字节码文件。

《多线程与高并发(三)synchronized关键字》

在红色框中我们看到执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一

任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态。

《多线程与高并发(三)synchronized关键字》

任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

2.2 synchronized的happens-before关系

我们再看看Synchronized的happens-before规则,看下面代码:

 

public class MonitorDemo {
    private int a = 0;
    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }
 }

该代码的happens-before关系如图所示:

《多线程与高并发(三)synchronized关键字》

在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?

根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

2.3 锁获取和锁释放的内存语义

在上一篇文章提到过JMM核心为两个部分:happens-before规则以及内存抽象模型。我们分析完Synchronized的happens-before关系后,还是不太完整的,我们接下来看看基于java内存抽象模型的Synchronized的内存语义。

废话不多说依旧先上图。

《多线程与高并发(三)synchronized关键字》

从上图可以看出,线程A会首先先从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加一操作后,再将该值刷新到主内存,整个过程即为线程A 加锁–>执行临界区代码–>释放锁相对应的内存语义。

《多线程与高并发(三)synchronized关键字》

线程B获取锁的时候同样会从主内存中共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。

从整体上来看,线程A的执行结果(a=1)对线程B是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。另外也验证了2 happens-before 5,2的执行结果对5是可见的。

从横向来看,这就像线程A通过主内存中的共享变量和线程B进行通信,A 告诉 B 我们俩的共享数据现在为1啦,这种线程间的通信机制正好吻合java的内存模型正好是共享内存的并发模型结构。

3.4 对象头

在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头。

jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:

《多线程与高并发(三)synchronized关键字》

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构

《多线程与高并发(三)synchronized关键字》

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

《多线程与高并发(三)synchronized关键字》

三、优化

从上面学习的内容来看,synchronized具有互斥性(排它性),这种方式效率很低下,因为每次都只能通过一个线程。

synchronized会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

我们每天挤地铁过安检的时候,都需要排队,排队的时候我们需要将包放入安检机,但这些都非常耗时,想要加快速度,那这个时候就有了人工检查,人工检查大大的减少了检查的时间,这样我们排队速度也就更快,这就是一个优化思路。

3.1 CAS

CAS是一个非常重要的概念,但是又很抽象,没办法,只能死磕了。

3.1.1 CAS是什么

从思想上来说,Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

3.1.2 CAS的操作过程

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值O,要修改的新值N。

更新一个变量的时候,只有当变量的预期值O和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为N。

如果O和V不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。

元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现,关于这些具体的实现场景在之后会详细聊聊,现在有个印象就好了(微笑脸)。

3.1.3 CAS的问题

1. ABA问题 因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。

2. 自旋时间过长

使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

3. 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

3.2 优化

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

3.2.1 偏向锁

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

获取偏向锁

当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

《多线程与高并发(三)synchronized关键字》

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

关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。

3.2.2 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

3.2.3 自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

3.2.4 各种锁的比较

《多线程与高并发(三)synchronized关键字》

这篇的内容有些抽象,主要参考的是《Java并发编程艺术》

相关文章