对于任何Java开发者来说多线程和同步是一个非常重要的话题。比较好的掌握同步和线程安全相关的知识将使得我们则更加有优势,同时这些知识并不是非常容易就能熟练掌握的,实际上写出正确的并发代码是一件比较困难的事情。在Java的自带的库里面,已经包含了非常多实用的并发工具类,今天这篇文章,我们主要来学习Java里面synchronized关键字的相关知识。
在这之前,我们应该知道Java里面已经提供了不少的同步工具类,如volatile关键字,atomic变量,
synchronized关键字,Lock接口及其比较常用的实现类ReentrantLock,ReentrantReadWriteLock
因为synchronized出现的较早,所以我们更应该理解其与其他同步工具的区别和联系。在多线程程序里面存在死锁,数据竞争,线程安全等一系列问题,清晰的理解同步概念是我们写出正确程序的重要保障。
### 线程同步是什么
同步是Java多线程编程里面重要的概念,我们知道Java是一门多线程编程语言,可以充分的利用当代cpu多core的优势,当多个线程并发或者并行的修改或者访问共享变量时,可能会出现内存不一致的错误。为了避免这些错误的发生,我们需要让我们的代码合理的同步通过互斥来保证对于临界区资源的访问不能同时存在多个线程访问。
### 为什么需要线程同步
在一个多线程的应用里面,如果你的代码里包含了状态可变的共享变量,那么为了避免共享变量的对象状态出现问题或者发生一些不可预知的行为,你需要通过同步操作来确保程序正确的运行。当然如果你的共享变量的仅仅是只读的或者是不可变的对象,那么你完全不需要同步操作。在java里面同步操作可以保证在任何时候同步的数据块只能有一个线程可以访问。
### 关于synchronized关键字
synchronized关键字是Java里面被大量使用的一个同步工具,它的一些功能如下:
(1)提供了锁操作,可以对于共享资源的访问进行同步从而避免数据竞争
(2)可以避免部分重排序问题,注意是部分不是所有
看下面一段代码:
``` public void demo1(){ //1 int a=1; int x=3; // 2 synchronized (this){ int b=5; StringBuffer buffer=new StringBuffer("abc"); int c=6; } //3 int e=4; int y=7; } ```
上面的代码我分了三部分,其中1,2,3总体执行顺序不会变,因为中间的是同步块,避免部分重排序,但是1,2,3块内部是可以执行重排序的,比如a和x是可有可能重排的,e和y也是有可能重排的,b和c变量是有可能重排的,buffer变量自身都有可能重排,这是因为对象的初始化包括三步:分配内存,初始化构造函数,引用地址,这也是为什么在双检锁里面单例的变量仍然需要volatile关键字来修饰的原因,通过volatile关键字可以保证对象初始化是原子的,内部是设立内存屏障把读操作屏蔽在写操作完成之后。
(3)自动包含加锁和释放锁两个功能。当线程进入一个synchronized修饰的方法或者代码块,它先需要获取锁,获取之后会自动的从主内存获取数据而不是自己的local cache中,当它释放锁的时候,会刷新写操作进入主内存中从而消除内存不一致的问题。
(4)使用方式有同步块和同步方法两种,注意其不能修饰变量,否则会编译错误。 部分场景下如保证可见性,可以使用volatile关键词来完成。除非另有说明大多数情况下应该优先使用同步代码块而非同步方法,仅仅锁住需要加锁的部分代码,而不是为了省事直接锁住整个方法这样会导致更低的效率。
(5)进入临界区需要获取锁,退出临界区会释放锁,这里需要注意的是如果在临界区发生未知异常或者错误,或者执行了break,return,Java仍会保证释放锁。
(6)同步块的条件不能是null,否则会抛出空指针异常
(7)synchronized的一个主要缺点是,不允许并发读,这在一些场景下会降低应用的吞吐量,我们可以通过jdk5之后的读写锁来规避这个缺点。
(8)这里的同步仅仅在一个jvm进程中,如果你需要在多个jvm里面实现同步或者互斥操作,需要考虑使用分布式锁如zookeeper,或者redis等
(9)对于同步的静态方法和非静态方法是可以同时访问的,因为他们加锁的一个是类,一个是实例。
(10)在java5之后,通过volatile修饰的变量,可以保证声明赋值的过程是原子的,尤其在基本类型里面要注意long和double的变量声明赋值,默认不是原子的,如果要在多线程里面使用应该优先考虑使用volatile保证声明的原子性。另外volatile在这种场景性能更优于synchronized关键字。
(11)synchronized使用不当会导致死锁和活锁,这里需要注意。
(12)synchronized不能用于修饰构造方法。这一点看起来比较奇怪,其实思考一下,也有道理。因为即使你对构造方法加锁,它仍然会出现由于重排序导致不
正确的对象的状态被泄露,这一点我在双检锁深入分析时提到过。
(13)synchronized不能修饰变量,volatile关键字不能出现在方法内
(14)java并发包里面提供了更加完善和性能更好的Lock对象。比如被synchronized等待的或者阻塞的线程是没法被打断或者超时的,这个可以在java5之后新的并发包里面使用ReadWriteLock和ReentrantLock来解决,其次在新的并发包里面我们可以对锁的控制粒度更细,比如在一些场景下我可以在一个方法中获取锁,在另外一个方法中释放锁,这是synchronized做不到的。另外,新的并发包我们可以实现非阻塞的操作,通过tryLock方法,如果在synchronized块中,一但有人占用锁,你必须无限的等待,中间什么都不能干,而使用tryLock方法,我们可以知道有人再占用锁,我们先去忙自己的,一会再来看看是否还有人占用,这就是典型的非阻塞。最后新的并发工具底层通过组合使用CAS操作,volatile变量和atomic变量以获得更好的性能。
(15)不推荐使用非final字段作为synchronized block的锁条件,也不推荐使用String类型作为锁条件,因为其引用可变,最佳做法是使用final修饰的Object对象。
### 关于volatile关键字
在这里我们再复习下volatile关键字的功能,这里有一个简单的例子:
``` //x、y为非volatile变量 //flag为volatile变量 x = 2; //语句1 y = 0; //语句2 flag = true; //语句3 x = 4; //语句4 y = -1; //语句5 ```
volatile可以禁止重排序保证部分的有序性,比如上面的语句,第三个变量是volatile修饰的,这样一来语句3不会被放到语句1和2前面,也不会放到4和5后面,但语句1和2的顺序不保证,同理4和5的顺序不保证。
另外一个例子:
``` //线程1: context = loadContext(); //语句1 inited = true; //语句2 //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context); ```
上面的代码在单线程里面没有问题,在多线程就不一定了,如果语句1和2发生重排序,语句2线执行,同时另外一个线程2刚好访问语句2,这样就会发生重排序导致不一致问题。这个时候我们通过volatile修饰语句2就可以避免重排序,同时由于可见性,线程2能及时感知变化就可以正常访问。
### 对比synchronized和volatile
我们需要从三个方面原子性,可见性,有序性来看他们:
(1)原子性:
synchronize可以保证在本线程内多个步骤操作的原子性,即同一时刻只能有一个线程操作。线程外不保证,参考双检锁的问题案例。
volatile可以在多线程下仅保证单个步骤的原子性,比如变量的赋值。
(2)可见性:
这里我想强调的是volatile是无条件的可见性(jvm保证),不需要额外的条件,其他的线程都能看到,这里有一点需要注意对于引用类型,volatile只保证引用可见,不保证引用内容可见,比如数组或者对象。synchronized关键字是有条件的可见性,其他的线程必须也是通过synchroinized一样的monitor条件才能看到最新的变化,否则是不确定的。
(3)有序性:
都只保证部分有序性
反思:
通过上面的对比想告诉大家不要认为synchronized或者Lock是万能的,他们与volatile不是互斥的关系,其实很多场景下都需要volatile和synchronized的配合才能编写出正确的多线程代码。
### 总结
本篇文章主要介绍了在Java多线程编程里面同步概念的一些相关知识,并重点介绍了synchronized关键字的一些特点以及它的优缺点,在文末还介绍了其与volatile关键字的对比。正确的编写多线程程序不是一件容易的事情,我可以告诉你,你在你的电脑上跑了一千万次都验证结果是正确的多线程程序,真正的结果却不一定是正确的,不要怀疑,因为不同的硬件系统的CPU指令集是不一样的,你仅仅能证明在你的电脑上可能是没问题的。只有正确的理解和掌握JMM内存模型才能使得我们编写多线程程序更加安全和健壮。