本文站在多线程初中级学习者的角度,较为全面系统的带你一起了解多线程与锁相关的知识点。带你一起解开与锁相关的各种概念、用法、利弊等。比如:synchronized、Volatile、Lock、ReentrantLock等。
本讲是Java多线程知识点的重要一环,随着CPU、内存的升级换代,掌握多线程编程显得越来越重要。
synchronized相关知识点较多,底层是C++,本文不对理论知识做过多的深入研究,主要阐述核心理论、及其关键字的使用。
前方预告:本文满满的干货,不涉及过深且难懂的理论知识,点到为止(不做深入扩展),总有适合你口味的点。
注:为缩减篇幅,精简的大白话可描述清楚的,就不再逐一配图,如有疑问可留言探讨。
*****************¥¥¥¥¥下面是前置知识点(重要)¥¥¥¥¥*****************
电脑最早期是单内核单线程,后期有了内存概念,再后来为了提高运行效率,随着软硬件的升级换代,内存的脚步越来越赶不上CPU的运行,进而衍生出在CPU上开辟高速缓存。从而就会出现,多核CPU、多内存协同工作,势必会出现数据一致性、原子性问题。
工作原理:synchronized会从主内存把变量读取到自己工作内存(同时给主内存的该共享变量上锁,只可访问不能修改),在退出的时候会把工作内存的值写入到主内存,保证了原子性。
synchronized是Java保留的关键字,它可以用来控制线程的同步,简单的说synchronized就是控制代码段不被多个线程同时执行,使其有序执行。
使用场景:synchronized主要作用在(静态/非静态)方法或方法内的代码片段上。
public synchronized void method() {}
public synchronized static void method() {}
一旦作用在静态方法上,相当于锁住了整个类,则多线程调用该对象的方法时,各线程之间只能共享一把锁,这些线程需要排队等待执行该静态方法。
作用在非静态方法上,就不一样了,各线程之间的锁之间互不影响(相当于new了类的多个实例,实例之间调取同一个方法互不影响)。
下面两种方法写法不同,但是作用等价
//方法一
public synchronized void method()
{
// todo
}
//方法二
public void method()
{
//此处的this指的是当前类,例如:MyTest.class
synchronized(this) {
// todo
}
}
这里需要提示的是:每个类只有一个 Class 对象,所以每个类只有一个“类锁”(下面会有介绍),据此可以保证操作的原子性。
注意:
1、如果方法体比较大,需要同步的代码只是一小部分,如果直接对整个方法体进行同步,会使得代码性能变差,这时只需要对一小部分代码进行同步即可。
2、synchronized 不能继承,父类的 synchronized 方法被子类重写后默认不是 synchronized 的,必须显示指定
3、接口的方法和构造方法不能加 synchronized 关键字
它们的更多细节,见下面的:对象锁和类锁。
早期的synchronized是重量级的,是笨重的,后来在jdk1.6对其进行了一次优化升级,使之有4种状态:无锁、偏向锁、轻量级锁、重量级锁。它们之间可以由低向高通过JVM自动切换,但是不可逆向切换。
偏向锁:它主要应用于单线程,效率比较高。
轻量级锁:线程一旦超过1个,被synchronized作用的对象锁的状态会自动从偏向锁切换到轻量级锁,优点是效率高、低延迟,但是吞吐量有限。
重量级锁:在多线程高并发的情况下,轻量级锁会自动升级为重量级锁,好处是提高了吞吐量,但会略有延迟。
下图,从右向左看,0 0 是轻量级锁,1 0 重量级锁,1 1 是GC。
synchronized在JKD1.6以前是重量级锁,当前只有一个线程执行,其他线程阻塞。为了减少获得锁和释放锁带来的性能问题,而引入了偏向锁、轻量级锁以及锁的存储过程和升级过程。在1.6后锁分为了无锁、偏向锁、轻量锁、重量锁,锁的状态在多线程竞争的情况下会逐渐升级,只能升级而不能降级,这样是为了提高锁获取和释放的效率。
注:锁的升级过程,只可升级不可降级
偏向锁:当new一个对象的时候,单线程情况下,默认会为该对象添加一把偏向锁,但是加锁的过程是有延迟的,默认是4秒,在4秒之前细分的话,该对象的锁叫偏向锁未启动;在4秒后该对象的锁自动变为偏向锁已启动。
举例:好比,火车上厕所的坑位,整个火车就1名乘客(单线程),别无他人,这个坑位(锁)就一直属于一个人。
注意:
1、在JDK15以下,一开始就知道有两个及以上的线程竞争的话,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭。
2、JDK15中开始废弃偏向锁,原因是当下多线程应用越来越普遍的情况下,偏向锁带来的锁升级操作反而会影响应用的性能。 老版本JDK中的HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全,单线程的情景下使用这些集合库就会有不必要的加锁操作,从而导致性能下降。高版本JDK,提供了一系列多线程情况下更安全高效的集合库ConcurrentHashMap、CopyOnWriteArrayList 等。
点击进入官方文档:
Deprecate and Disable Biased Locking(弃用并禁用偏置锁定)
Goals
Determine the need for continued support of the legacy synchronization optimization of biased locking, which is costly to maintain(维护成本太高).
Motivation
The performance gains seen in the past are far less evident today. Many applications that benefited from biased locking are older, legacy applications that use the early Java collection APIs, which synchronize on every access (e.g., Hashtable and Vector).
…………To that end we would like to disable, deprecate, and eventually remove support for biased locking.
自旋锁:自旋锁就是轻量级锁,当JVM明确知道有多线程的时候,需要直接关闭偏向锁,使用自旋锁;当然,JVM意识到对应同一个对象单线程变成多线程时,偏向锁也会自动升级为自旋锁。
早期的自旋的次数默认为10次,用户可以通过-XX:PreBlockSpin来进行更改,随着在JDK1.6自适应自旋锁的出现,JVM会动态着根据实际情况来改变自旋等待的次数。
所谓的自旋,就是自旋等待,不断的查看自己是否能够获取到锁,把自己的LR写到“厕所门”上,独占厕所。
举例:火车上厕所某个坑位的使用,多于1个人要使用时,A已经在使用了(拿到锁),就会把自己的LR(Lock Record)贴到“门”上,直到自己方便完会撤下自己的LR。那么作为B、C、D,它就一直要运行类似于一个“空的for循环”,在厕所门口自旋等待,看A的LR撤下来没有。
需要注意的是:synchronized下的锁,并不是公平的先到先得,而是由JVM内部的一些算法决定。所以它下面自旋锁、重量级锁也都是非公平锁。
轻量级锁(线程之间高并发,竞争加剧)膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
假如我们一开始就知道某个同步代码块的竞争很激烈、很慢的话,那么我们一开始就应该使用重量级锁了,从而省掉一些锁转换的开销。
公平锁(FairLock):顾名思义,公平锁遵循先进先出FIFO原则,也就类似于厕所的排队(_waitSet队列)也就是线程阻塞的顺序,排在最前面的优先得到锁,常见是公平锁算法是CAS。
公平锁会根据线程获取锁的时间来判断,等待时间越久的线程优先被执行,所以效率较低,因为需要判断线程的等待时间。
ReentrantLock是Lock接口的唯一实现类,new初始化它的时候,需要显示指定为new ReentrantLock(true)开启公平锁,默认为false(非公平锁)。
非公平锁(UnFairLock):不遵循先进先出,里面涉及到一些算法,抢占锁资源,不能保证一定会根据锁的线程优先级来获得锁。效率较高,因为获取锁是竞争的。
synchronized实现的锁,只能是非公平的强制锁,对于一些线程,可能长久无法抢占到锁,导致处于饥饿状态,对于某些特定的业务场景,必须要使用公平锁,这时,synchronized同步锁无法满足要求。
拿厕所排队来说,好比紧急的优先、女士优先、小孩优先什么的,并不一定会让排在最前面的优先,其中synchronized就是非公平锁。
可重入锁(ReentrantLock):可重入,本意就是一个对象可以根据需要重复多次的拿到锁的使用权,而不是只有一次锁的使用权,也就是说已经获取锁的线程可以再次获取锁,其中Lock和synchronized都是可重入锁。
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。它是一种思想,不限定于某种开发语言或者数据库(如MySQL中的排它锁)。
乐观锁:乐观锁在操作数据时非常乐观,认为自己修改数据时,别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据,如果别人修改了数据,则放弃操作,否则执行操作。
好比上厕所,你厕所大门敞开着,乐观的认为不会有任何人过来打扰或者偷窥你。
常见的乐观锁算法是CAS(Compare And Swap),CAS算法是不需要锁的。需要注意的是,从 CPU 的角度来看,CAS 其实是一个开销很昂贵的操作(底层是do while循环,有十万个线程来并发处理,假设每个线程为了保证原子性,循环耗时0.001s的话,那么十万个线程都这么循环下来,对CPU的消耗还是比较大的)。
悲观锁:悲观锁在操作数据时比较悲观,认为自己在修改数据时,别人也会同时修改数据。
因此操作共享资源时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据,据此保障数据的原子性。
常见的悲观锁:排它锁(mysql做update操作时会启用该锁)、被synchronized修饰的代码块或方法锁定的对象。
两种锁力量对比
乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;
当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
实际开发中,乐观锁的使用场景受到较多限制,尽管CAS可以通过版本号处理ABA问题。
例如,CAS(乐观锁机制)只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。
再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
3.3.1 CAS原理
比如需要对num进行+1操作,取的时候是0,往里面+1的时候,再读取一次,如果还是0,说明中间没有没打断过,就直接+1,如果中间被打断过,则取到最新值后,再次判断,循环往复直至中间没有被打断过。
3.3.2 CAS弊端:
1、ABA问题
例如,操作一个对象num时,操作前读取到的是0,写操作时,再对比一次是否是0,虽然前后两次读num都是0,但不能保证中间被绿过多次,也就是说0被修改为其他数值后,又被修改为0,此时的0已经是被绿过的0了)。
再比如,你账号有¥100,小C朋友偷偷刷了100买了游戏装备,小D欠了你¥100,刚好这个点给你转账100,此时你查看自己账号依然有100,但此时的100,已经经历些了什么。
这个就是ABA问题,针对于该问题,在单个共享变量下,可以用版本号解决。
//CAS底层源码片段
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
之前的 CAS 只有两个参数,带上版本号比较的 CAS 就有四个参数了,其中 expectedReference指的是变量预期的旧值, newReference 指的是变量需要更改成的新值, expectedStamp 指的是版本号的旧值, newStamp 指的是版本号新值。
注:如果使用CAS不能妥善的解决ABA问题,尤其是在金融系统,容易造成经济损失。
CAS的操作是将Integer这样的对象包装为AtomicInteger,同时重新定义了Integer对象,
int num=0;
num++
//转换后
AtomicInteger inc=new AtomicInteger(0);
inc.getAndIncrement();
volatile主要提供共享变量的可见性,AtomicInteger内部维护的int值是用volatile声明的,所以AtomicInteger保障原子性的同时,也保证了可见性,不需要额外再用volatile声明。
注:具体用法会在下面的实战环节略微介绍该类的简单使用。
2、功能限制,只支持单个共享变量
CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:
(1)多线程下,单独使用CAS不一定能保证线程安全,例如在Java中需要与volatile(修饰共享变量)配合来保证线程安全;
(2)当涉及到多个共享变量(内存值)时,CAS也无能为力(比较优秀的是synchronized)。
除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic(包含AtomicBoolean、AtomicLong等类)包下的原子类使用,灵活性受到限制。
3、高并发高竞争环境下,CPU损耗较大
如果CAS一直失败,会一直重试,CPU开销较大。
针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。
当然,更重要的是避免在高竞争环境下使用乐观锁,悲观锁是一个不错的选择。
独占锁(排它锁):每次只能有一个线程能持有锁,如,Lock(接口)、synchronized实现的是独占锁,也称作是排它锁,即该锁只能被一个线程所持有,其他线程均被阻塞等待,从而达到原子性操作。
共享锁:允许多个线程同时获取锁,并发访问共享资源,如ReadWriteLock(接口),它是实现类有两个分别是ReadWriteLockView、ReentrantReadWriteLock。
对象锁和类锁:提到这个概念,需要首先明确的是类锁是不存在的,它是只是一个概念上的东西,它主要是用来帮助我们理解,锁定实例方法和静态方法的区别的。
从操作层面理解这个两个锁,对象锁是指synchronized作用在非静态方法上,而类锁是指synchronized作用在静态方法或者一个类的class对象上。从这点就可以看出两者还是有很大的不同。
作用在非静态方法上,一个类就可以new出多个实例,多个实例之间的锁相互是没有关系,互不影响的。反之作用在静态方法上,锁的对象只有1个实例。
注意:
1、如果一个类有两个方法,内容一致,但是一个被static修饰的静态方法,一个是非静态的方法,则两个方法的锁不是同一个。
2、如果一个类有两个方法都是静态的,则两个方法使用的是同一个锁,只有先执行的方法执行完毕后,下一个方法才可以依次执行。
多线程编程下,可见性、有序性、原子性是绕不开是话题,它们都可以保证对象的可见性和有序性。主要区别之一,volatile不能保证共享变量的原子性,volatile主要是保证可见性。
可见性:多线程编程下,共享变量默认一方修改,是不能立刻被其他使用该变量的线程感知的,但是在共享变量前,添加关键字volatile后,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值,当然Lock也可以实现可见性(都会在释放锁之前,会将对变量的修改刷新到主存当中)。
有序性:程序执行的顺序按照代码的先后顺序执行。
原子性:一个操作或者多个操作,要么全部执行(执行的过程不会被任何因素打断),要么就都不执行。
Lock是一个接口,它是在jdk1.5引入的,里面定义了6个方法,ReentrantLock是Lock的唯一实现类。
疑问:有了synchronized,为什么还要引入Lock呢?
1、synchronized 和 ReentrantLock的lock方法,虽然语义相同(都是上锁),但是后者有更高的性能,使用也更为灵活(有6个方法),后者是使用原子变量来维护等待锁定的线程队列。
2、synchronized获取锁的时候只能一直等,没有超时机制,也不能被打断,而且锁的获取和释放必须在一个方法内,后者相对可以被interrupt()方法打断,可以通过tryLock指定尝试取锁时间,并且有返回值。
特别注意:Lock,需要手动释放锁,unlock代码必须写在finally块中 。
lock接口内部定义的6个方法(提高了Lock的灵活度)分别是:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
注:newCondition()方法便于用户在同一锁的情况下,根据不同的情况执行等待或唤醒的动作。
lock()、tryLock()、tryLock(long timeout, TimeUnit unit)、lock.lockInterruptibly()
lock()方式:该种方式获取锁不可中断,如果获取不到则一直休眠等待。
tryLock()方式:直接获取到锁返回值true,否则返回值为false。它不用阻塞等待去排队获取锁。
tryLock(long timeout, TimeUnit unit)方式:在超时时间内获取到锁,返回值为true,否则返回值为false。在超时时间内,如果被interrupt()中断,则该视为该线程放弃锁的争取权,并抛出InterruptedException。
lockInterruptibly()方式:获取到锁则返回true,反之会一直处于等待状态,直到被interrupt()中断或者获取到锁。同样,一旦被中断就会放弃当前锁的争取权。
使用lockInterruptibly()方法的目的就是为了允许该方法在没有获取锁之前,允许通过interrupt()方法中断。
注意:
1、已经获取到锁的线程是不能被interrupt()中断的。
2、获取锁的方法,需要写在try的外侧(避免未得到锁,而释放锁),unlock()必须手动写在finally代码块中(确保锁最终,一定可以被释放,避免死锁)
代码片段片段示例:
private Lock lock= new ReentrantLock();
lock.lock();
if(lock.tryLock())
finally{
lock.unLock();
}
它是Lock接口的唯一实现类,是可重入锁,互斥锁,提供了fair和unfair两种模式的锁。默认构造函数是unfair的锁,如果new初始化时传入true的参数则会返回fair锁。所谓不公平就是在锁可获取时,不用考虑该锁队列是否有其他waiter,直接获取;反之,对于公平锁来讲就是当等待的锁资源可获取时,要看等待队列中当前线程是不是head线程,如果不是则不获取锁。
简单的说,它默认是非公平锁,如果要实现公平锁,需要new ReentrantLock(true),传入true才可以。公平锁会考虑首先把锁的使用权让给排在队列中最前面的线程,也就是FIFO先进先得的公平。
Lock机制加上了读操作并发,当同步锁竞争资源不激烈的时候,synchronized和Lock锁的性能是差不多的,当竞争资源非常激烈时(即有大量线程同时竞争),Lock的性能要远远优于synchronized。
synchronized操作更为简单,锁可以自动升级,而且不需要手动加锁、解锁,也不会产生死锁,一切有JVM自主决策;Lock提供了更为自主可控的一些方法,相对更为灵活一点,需要手动在finally代码块unlock释放锁。
synchronized是Java语言的内置的关键字,而Lock是一个接口,synchronized是原生语法层面的互斥锁(独占锁),ReentrantLock是API层面(独立的一个类)的互斥锁。
synchronized不需要用户去手动释放锁,即使发生异常也会主动释放占有的锁;lock需要手动的获取解锁(一般写在finally代码块中),如果忘记unlock解锁会产生死锁。
synchronized可用于修饰方法、代码块;Lock只适用于代码块锁。
等待时间:synchronized属于无限期等待,直到获取到锁或者抛出异常;Lock提供了4种获取锁的方式,可在lock方法中设置等待时间。
可否被中断:synchronized等待期间不可以被主动中断;Lock获取锁的方式如果是tryLock(long timeout, TimeUnit unit)、lockInterruptibly(),锁是可以被中断的。
注:Lock不带参数的tryLock()方法,是一锤子买卖,能直接获取锁就返回true,不能直接获取锁就返回false,没有等待时间,所以以该方式获取的锁不存在中断一说。
返回值:synchronized获取锁后没有返回值,Lock通过tryLock()方法获取到锁后,有返回值。
synchronized本身就是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过构造方法ReentrantLock(true)来要求使用公平锁(底层由Condition的等待队列实现)。
Lock可以通过newCondition()方法,绑定多个条件(条件变量或条件队列)。而在synchronized中,自动实现wait()和notify()或notifyAll(),如果要和多个条件关联时,就不得不额外地添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。而且我们还可以通过绑定Condition对象来判断当前线程通知的是哪些线程(即与Condition对象绑定在一起的其它线程)。
这里简单的提及一下condition.await() 方法被调回后的结果:
它会让使用该方法的对应线程一直处于等待状态,直到被中断或唤醒,同时会释放锁(如果不释放锁,别的线程就无法拿到锁而发生死锁)。
但是:当从await()方法返回的时候(有可能是被唤醒或者指定等待时间结束),一定会获取Condition相关联的锁(该锁和原来释放的锁等价)。
注:如果想深入了解,可以在底部实战环节的第7、第8个示例中有所展示!
synchronized较为适用于代码量比较少的方法或者代码块,Lock适合代码量稍微较大的代码块。
wait和notify、notifyAll 是配合synchronized使用,await和signal、signalAll是配合lock使用,区别在于唤醒时notify不能指定线程唤醒,signal可以唤醒具体的线程,更小的粒度控制锁。
注:前者是Object类的方法,后者是ReenTrantLock.newCondition()的返回值Condition类对象的方法。
synchronized因为可重入,因此可以放在被递归执行的方法上,不用担心线程最后能否正确释放锁;
ReentrantLock在重入时,要确保重复获取锁的次数,必须和重复释放锁的次数一样,否则可能导致其他线程无法获得该锁。
1、两者都是接口,都归属于 java.util.concurrent.locks这个package;其中ReadWriteLock接口只有两个方法,返回值都是Lock。
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading
*/
Lock readLock();
/**
* Returns the lock used for writing.
*
* @return the lock used for writing
*/
Lock writeLock();
}
2、Lock的唯一实现类是ReentrantLock,可以直接对象.lock()获取锁;ReadWriteLock的唯一实现类是ReentrantReadWriteLock,只能间接获取lock,比如 rwl.readLock().lock()、rwl.writeLock().lock();
3、Lock属于独占锁,一旦上锁是拒绝外部访问的,ReadWriteLock共享数据,只能有一个线程能写该数据,但可以有多个线程同时读该数据。
4、两者的lock和unlock等方法,只能在方法体内被调用,不可在方法体外部单独使用。
注:在细节和使用方面,还会有其他的区别和联系,不再逐一列举,更多示例,见下面的实战环节。
上面谈了这么多,如果你是刚开始接触多线程,即使把以上知识点全部掌握,此时,你依然说不清楚锁到底是个什么东东。
至此,再谈论锁是什么的问题,将会是水到渠成了。
锁,这个概念比较抽象,拿到锁,就意味着拿到了CPU的执行权。
拿3个人看电视来说,锁就好比遥控。
A拿到遥控了,如果A仅仅是想休息一会儿,并不像放弃遥控的持有权,那么就调用sleep(1000)方法。然而,管理员来了,对A说,我限定你N秒内,交出遥控,此时就调用wait(100000)方法,此时A会立刻丢失遥控的所有权(直到10秒后才会参与再次竞争),剩余的所有人按优先级,重新争取遥控。
通过这个例子,相比你会大概了解到获取锁、占有锁、释放锁之间的相互关系了。
简单的讲,等待(wait)队列就是阻塞(blocked)队列(没有被CPU直接执行的权利),就绪队列就是说我准备好了,CPU你来执行我吧。
举例:CPU好比皇帝,等待队列里是一个个国色天香的美女,但不是每个美女都有给皇帝侍寝的权利,规定只有被选定为嫔妃、贵人、答应这些才有侍寝的权利,那么就绪队列就是这些嫔妃们,获取到了侍寝权(锁),皇帝(CPU)在晚上翻牌时,只会翻到这些拥有锁的嫔妃们。
CPU竞争策略有多种,Unix使用的是时间片算法,Windows属于抢占式。
1.时间片
所有进程排成一个队列,操作系统按照他们的顺序,给每个进程分配一段时间,即允许该进程运行的时间。
2.抢占式
如果一个进程得到了 CPU 时间,除非它自己放弃使用 CPU,否则将完全霸占CPU,因此,在抢占式操作系统中,操作系统假设所有的进程都是“人品很好”的,会主动退出 CPU 。
了解了这些知识点,就可以做到兜里有粮,心中不慌。下面的一些实战将会理解的更为深刻。
¥¥¥¥¥¥¥¥¥¥下面是实战环节¥¥¥¥¥¥¥¥¥¥
温馨提示:
实战,是对上面理论知识的补充,看完下面的示例更有助于理解以上知识点。
由于涉及代码,显得略长,可根据自己兴趣,根据小标题,选择性查阅!
预告:单纯的使用volatile不能保证结果每次都是10000,使用synchronized可以保证结果都是10000。
也就是说:Volatile不能保证原子性操作,synchronized和atomic包下的这些类(AtomicInteger、AtomicLong)可以保证原子性操作。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class VolatileTest {
public volatile int num=0;
public /*synchronized*/ void increase() {
num++;
}
public static void main(String[] args) {
final VolatileTest test=new VolatileTest();
for (int i = 0; i < 10; i++) {
//开启10个线程,对num同时操作
new Thread() {
public void run() {
for(int j=0;j<1000;j++) {
test.increase();
}
}
}.start();
}
while(Thread.activeCount()>1) {
Thread.yield();//保障前面的线程都执行完
}
System.out.println(test.num);
}
//本案例问题所在,开启了10个线程,每个线程对num循环1000次自增1,
//常规思路是对num做了10个1000次的操作,num最终的值应该是10*1000=10000才对
//然而结果并不能保证结果一定是10000,应为volatile不能保证原子性操作
//解决办法一:只需要在increase()前面添加关键字synchronized就可以了,此时共享变量num前面带不带volatile已经无关紧要
//解决办法二:使用CAS操作,也就是把int num修改为AtomicInteger num,同时把i++修改为num.getAndIncrement()即可。
//解决办法三:使用Lock操作,核心代码如下
/*Lock lock =new ReentrantLock();
public void increase() {
lock.lock();
try {
num++;
}finally {
lock.unlock();
}
}*/
}
修改increase()方法,给其添加synchronized关键字后,再次运行,每次都是10000
public synchronized void increase() {
num++;
}
案例中,还提供了其他两套解决方案,自己试一试吧!
如果单纯的使用volatile作用共享变量,且在main方法中没有下图中被注释代码托底的话(这句话的作用是:只要当前线程是数量大于1,说明还有子线程在运行,那么主线程就一直yield,借此让其它线程执行,确保子线程都执行完毕),就会出现,子线程还在运行,主线程已经运行完毕,且把num打印出来了。
正常情况下,最终的activeCount应该是1的。当然,即使这个activeCount的最终结果是1,单独使用volatile也不能保证结果的准确性(原子性)。
注:如果觉得上面的例子不够详细,看不懂,下面的详细解析版本
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class VolatileCompareTest extends Thread {
public static Counter1 counter1 = new Counter1();//仅仅使用volatile修饰共享变量
public static Counter2 counter2 = new Counter2();//使用synchronized修饰整个方法
public static Counter3 counter3 = new Counter3();//给计数器添加ReentrantLock重入锁
public static Counter4 counter4 = new Counter4();//把计数器声明为AtomicInteger对象
public static void main(String[] args) {
Thread thread=null;//使用弱引用,有利于GC的回收
for (int i = 0; i < 100; i++) {
thread=new VolatileCompareTest();//相当于当前对象new了100次;
thread.start();
}
while (Thread.activeCount() > 4) {
Thread.yield();//只要还存在子线程,主线程就一直yield,做出让步
}
//保证执行完毕,如果无法理解上面两行代码,可以设置休眠时间,如下
/*try {
Thread.sleep(10 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
System.out.println(Thread.currentThread().getName() + " counter1 =" + counter1.getCount());
System.out.println(Thread.currentThread().getName() + " counter2 =" + counter2.getCount());
System.out.println(Thread.currentThread().getName() + " counter3 =" + counter3.getCount());
System.out.println(Thread.currentThread().getName() + " counter4 =" + counter4.getCount());
}
private void addCount() {
//让么个class类中的各计数器,自增100次
for (int i = 0; i < 100; i++) {
counter1.setCount();
counter2.setCount();
counter3.setCount();
counter4.setCount();
}
}
@Override
public void run() {
addCount();
}
public static class Counter1 {
//仅仅使用volatile修饰共享变量(不能保证原子性,所以该关键字修饰的变量不能作为计数器)
private volatile int count = 0;
public void setCount() {
count++;
}
public int getCount() {
return count;
}
}
//synchronized
public static class Counter2 {
private int count = 0;
//关键字作用在方法上,保证变量的原子性操作
public synchronized void setCount() {
count++;
}
public int getCount() {
return count;
}
}
//Lock的ReentrantLock重入锁
public static class Counter3 {
private int count = 0;
Lock lock = new ReentrantLock();//声明重入锁(需要手动添加锁、释放锁)
public void setCount() {
lock.lock();//这行代码需要写在try的外层,避免未加锁而解锁(比如:加锁时发生异常,最后却无故释放锁的情况)
try {
count++;
} finally {
lock.unlock();//一定要写在finally中,确保锁最终一定被关闭的,避免死锁(Lock机制,无论发生异常与否都不会自动释放锁,必须由程序员主动去释放锁)
}
}
public int getCount() {
return count;
}
}
//java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的
public static class Counter4 {
private AtomicInteger count = new AtomicInteger();
public void setCount() {
count.getAndIncrement();//自增1
}
public AtomicInteger getCount() {
return count;
}
}
}
经过多次运行,可以看到只有单独不volatile修饰的共享变量,打印结果不符预期,再次证明它不能作为计数器使用,更不能保证原子性。
预告:实际开发中使用ReadWriteLock来实现缓存的代码实现。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockCacheTest {
private ReadWriteLock rwl = new ReentrantReadWriteLock();
private Map cache = new HashMap();//该缓存一般会在类加载的时候,做一下初始化,存放常用的一些键值
public static void main(String[] args) {
//ReadWriteLock接口的两个方法readLock、writeLock返回值都是Lock,所以用法和Lock类似
//ReadWriteLock接口的实现类是ReentrantReadWriteLock,两个方法具体实现上有所不同
ReadWriteLockCacheTest t=new ReadWriteLockCacheTest();
Object obj=t.getData("hello");
System.out.println(obj);
}
public Object getData(String key) {
rwl.readLock().lock();
Object value = cache.get(key);//优先在缓存中查找
try {
if (value == null) {
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
if (value == null) {
value=queryDB(key); //缓存中没有数据,就去查询数据库;
cache.put(key, value);//把新数据更新到缓存(下次就可以不用查数据库了)
}
} finally {
rwl.writeLock().unlock();//最后释放锁
}
rwl.readLock().lock();
}
} finally {
rwl.readLock().unlock();
}
return value;
}
public Object queryDB(String key) {
return "default";
}
}
import java.util.ArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTest {
private ArrayList arrayList = new ArrayList();
private Lock lock = new ReentrantLock(); //注意这个地方(所有线程会公用这一把锁)
public static void main(String[] args) {
final ReentrantLockTest test = new ReentrantLockTest();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
}
public void insert(Thread thread) {
//Lock lock = new ReentrantLock();//错误用法,会导致个线程之间的锁互不影响,从而达不到锁的目的
lock.lock();
try {
System.out.println(thread.getName()+"得到了锁");
for(int i=0;i<5;i++) {
arrayList.add(i);
}
} catch (Exception e) {
// TODO: handle exception
}finally {
System.out.println(thread.getName()+"释放了锁");
lock.unlock();
}
}
}
可以看到两个线程是依次得到锁,释放锁的,也就是说两个线程共用的是一把锁,只有前者使用结束后释放锁后,后面的线程才可以得到并使用锁。
然则,如果把Lock lock = new ReentrantLock();代码写在本案例中的insert()方法中时,运行结果如下:
明显可以看到,在前一个线程thread-0没有释放锁的情况下,后一个线程thread-1已经得到锁了,说明两个线程使用的并不是同一把锁,所以在使用锁的时候要注意规避这个问题,否则可能就达不到上锁的目的了。
1、本例中为什么lock.lock()方法一定要写在try 的外层?
答:避免未加锁但是解锁的情况。
解释:如果将lock.lock()写到try块中,lock.unlock()写到finally块,可能出现未加锁成功(加锁时发生异常)最后却无故释放锁的情况;
2、本例中为什么lock.unlock()必须放在finally块中?
答:为了避免死锁。
解释:Lock机制是即便lock时发生了异常,也不会自动释放锁,必须由程序员主动去释放锁。
所以,lock.unlock()必须放在finally块中,保证只要通过lock.lock()获得锁后,无论正常执行还是发生异常,最终锁一定会得到释放,最终目的是为了避免死锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockInterruptTest {
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
ReentrantLockInterruptTest testLock = new ReentrantLockInterruptTest();
MyThread aa = new MyThread(testLock);
MyThread bb = new MyThread(testLock);
System.out.println("启动aa线程");
aa.start();
System.out.println("启动bb线程");
bb.start();
System.out.println("尝试打断bb--begin");
bb.interrupt();//尝试阻断bb
System.out.println("尝试打断bb--end");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void insert(Thread thread) throws InterruptedException{
//手动上锁
lock.lockInterruptibly(); //注意,如果需要正确中断等待锁的线程,必须将获取锁放在外面,然后将InterruptedException抛出
try {
System.out.println(thread.getName()+"得到了锁");
for(int i=0;i<100;i++) {
if(i==20) {
System.out.println("终止for循环");
break;
}
//插入数据
System.out.println("i="+i);
}
}finally {
System.out.println(Thread.currentThread().getName()+"执行finally");
lock.unlock();//手动释放锁
System.out.println(thread.getName()+"释放了锁");
}
}
}
class MyThread extends Thread {
private ReentrantLockInterruptTest testLock = null;
public MyThread(ReentrantLockInterruptTest testLock) {
this.testLock = testLock;
}
@Override
public void run() {
try {
testLock.insert(Thread.currentThread());
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName()+"被中断");
}
}
}
启动aa线程
启动bb线程
尝试打断bb--begin
尝试打断bb--end
Thread-0得到了锁
i=0
Thread-1被中断
i=1
i=2
i=3
i=4
i=5
i=6
i=7
i=8
i=9
i=10
i=11
i=12
i=13
i=14
i=15
i=16
i=17
i=18
i=19
终止for循环
Thread-0执行finally
Thread-0释放了锁
在本例中的main方法中:aa线程先调用start()方法,bb后调用start()方法,又bb随后立即调用了interrupt(),由于两者使用的是同一把锁(都是通过private Lock lock = new ReentrantLock()的这把锁)所以在aa线程在运行结束之前不会释放锁。
通过结果可以得知,在aa线程运行期间(尚未释放锁),bb还没有运行时(两者是同一把锁,只有先得到锁的释放了锁,后面的线程才可以运行),被interrupt()方法阻断了,打印结果也就没有bb线程的打印信息。
提示:只有线程尚未得到锁时,才可以被阻断。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 公平锁模式下,线程的执行更为均匀(因为要雨露均沾,判断不同线程已经得到的执行机会的次数,效率略低)
* 非公平模式,各线程得到执行的机会是不同的(不需要雨露均沾,效率较高)
*/
public class FairReentrantLockTest {
public static void main(String[] args) {
Thread thread=null;//能使用弱引用,就不使用强引用(提供GC的回收效率)
for (int i = 0; i < 5; i++) {
thread=new PrintThread();
thread.start();//这里需要注意,并不是哪个线程先start,哪个线程先执行
}
}
static class PrintThread extends Thread {
//运行本示例时,分别把true、false替换其中,看打印结果的不同
static Lock lock = new ReentrantLock(true);//true、false
@Override
public void run() {
try {
Thread.sleep(1 * 100);
for (int i = 0; i < 3; i++) {
lock.lock();
System.out.println("当前获得锁的线程--->>>" + Thread.currentThread().getName());
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
该模式下,各线程得到的执行机会几乎的均等的。
static Lock lock = new ReentrantLock(true);//true、false
该模式下,各线程得到的机会不会每次都是均等的,一旦得到执行机会不会轻易的放弃。
static Lock lock = new ReentrantLock(false);//true、false
示例进行到这里,不谈一下Condition配合锁的使用,貌似是不完美的,总有缺憾,在此再次更新一下。
synchronized可以结合Object进行线程之间的通信,比如说wait和notify实现线程的等待和唤醒。
ReentrantLock也有,Java提供了Condition 接口,可以实现ReentrantLock线程之间的通信。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionTest {
//注意:AwaitThread类中通过condition调用await(),再在main方法中唤醒操作。
//前后使用的是同一把锁的同一个condition对象
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
System.out.println("主线程执行ing...");
new Thread(new AwaitThread()).start();//启动子线程
try {
Thread.sleep(2000);//让主线程休息2秒
lock.lock();
while(lock.isLocked()) {//需要先判断是否得到了锁
condition.signal();//在run方法中,调用await(),在此处唤醒线程
break;
}
} finally {
lock.unlock();
}
System.out.println("活跃线程的个数:"+Thread.activeCount());
while(Thread.activeCount()>1) {//尚有子线程继续处于活跃状态时
Thread.yield();//让主线程左yield,让出自己的CPU资源,让子线程优先执行
}
System.out.println("活跃线程的个数:"+Thread.activeCount());
System.out.println("主线程执行结束");
}
static class AwaitThread implements Runnable {
@Override
public void run() {
System.out.println("子线程执行ing...");
lock.lock();//切记,这个要写在try的外面,避免加锁时异常,未加锁成功后面释放锁。
try {
System.out.println("子线程停止了");
condition.await();//让子线程进入等待
System.out.println("子线程恢复执行了");//这是因为在main方法中,通过同一个condition调用了signal()对其做了唤醒操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();//最后不要忘记了,手动释放锁(lock即便是发生了异常,一旦加锁成功也不会主动释放锁,为了避免死锁,一定要在finally里手动释放锁)
}
}
}
}
在本示例中,简单演示了灵活让线程通过condition进入await等待和signal唤醒,好处也不言而喻,它可以有针对性的具体操作某个线程,而synchronized做不到这一点(内部是自动维护await和notify的)
示例进行到这里,可能会有小伙伴疑惑,Condition多条件绑定,到底怎么个绑定法,一起来看一下
import java.util.LinkedList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionBoundedQueueTest {
private LinkedList
在本例中,下面两行代码
fullCondition = lock.newCondition();
notFullCondition = lock.newCondition();
就属于多条件绑定,可以根据实际需求,绑定多个Condition,但是这些Condition都需要来自于同一个lock的lock.newCondition()的方式获取!
使用Condition的时候,有几个关键的点,不得不重申一下!
1.Condition必须要配合锁一起使用,一个Condition的实例必须与一个Lock绑定,但是一个Lock可以绑定多个Condition实例。因此Condition一般都是作为Lock的内部类实现。
2.Condition它是一个接口,它的唯一实现类是ConditionObject,但是平时几乎使用不到它的实现类。
日常开发中的使用经常是:Condition condition =lock.newCondition();
3.虽然condition 的使用方式非常的简单,但是需要注意在调用方法前获取锁。
也就是说,condition.await()这些方法必须写在lock.lock()和lock.unlock()代码块中间。
4.在调用condition.signal()唤醒线程的时候,保险起见,需要判断是否已经获取了锁,代码如下,具体用法,请仔细查阅上面的示例7。
lock.lock();
while(lock.isLocked()) {//需要先判断是否得到了锁
condition.signal();//在run方法中,调用await(),在此处唤醒线程
break;
}
至此,我想,关于锁使用层面的一些东东,我介绍的差不多了,当然还有一些未涉及的知识点,如感兴趣可在底部附注中点击进入,比如:全方位剖析守护线程的使用。
如想更深入的底层源码,比如加锁时给count set+1,给count +1,以及锁的底层原来MarkWord之类,请百度搜寻关键字:“JOL”(Java Object Layout),在maven工程的pom工程中引入:org.openjdk.jol后,即可跟踪源码的一些边边角角。
注:在时间和精力允许的情况下,会持续更新一些较深入的话题。
由于在浏览其他资料时,每个人描述问题的角度、深浅不同,要么过于深入看完表示懵逼,要么描述知识点过于单一,以至于和其他知识点难以连贯,故有此篇。
由于笔者才疏学浅、文笔拙劣,水平有限,在梳理过程中难免有失误,如有察觉,欢迎批评指正!
附注:猜你可能还会感兴趣
1、 JAVA多线程:synchronized理论和用法 | Lock和ReentrantLock Volatile 区别和联系(一)
2、JAVA多线程:yield/join/wait/notify/notifyAll等方法的作用(二)
3、JAVA多线程:狂抓!join()方法到底会不会释放锁,给你彻底介绍清楚(三)
4、JAVA多线程:sleep(0)、sleep(1)、sleep(1000)的区别(四)
5、JAVA多线程:彻底教你守护线程的使用 | thread.setDaemon(true) (五)
更多线程池知识点,持续更新中……