Java并发编程:Java中的锁和线程同步机制
锁的基础知识锁的类型锁从宏观上分类,只分为两种:悲观锁与乐观锁。 乐观锁乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。 悲观锁悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock自旋锁。 线程阻塞的代价Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。 明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一。 Java中的锁自旋锁自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。 但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。 如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。 自旋锁的优缺点自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换! 但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,占着XX不XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁; 自旋锁时间阈值自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要! JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化
自旋锁的开启JDK1.6中-XX:+UseSpinning开启;? 重量级锁SynchronizedSynchronized的作用在JDK1.5之前都是使用synchronized关键字保证同步的,Synchronized的作用相信大家都已经非常熟悉了; 它可以把任意一个非NULL的对象当作锁。
Synchronized的实现实现如下图所示; 它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。 OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。 处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。 Synchronized是非公平锁。?Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。 偏向锁Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。? 它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。 偏向锁的实现偏向锁获取过程:
注意:第四步中到达安全点safepoint会导致stop the word,时间很短。 偏向锁的释放:偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。 偏向锁的适用场景始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;? 查看停顿–安全点停顿日志要查看安全点停顿,可以打开安全点日志,通过设置JVM参数 -XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多; 注意:安全点日志不能一直打开:? 所以安全日志应该只在问题排查时打开。? 此日志分三部分:? 第三部分是到达安全点时的各个阶段以及执行操作所花的时间,其中最重要的是vmop
可见,那些很多但又很短的安全点,全都是RevokeBias, 高并发的应用会禁用掉偏向锁。 jvm开启/关闭偏向锁
轻量级锁轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;?
轻量级锁的释放释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。 因为重量级锁被修改了,所有display mark word和原来的markword不一样了。 怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。 此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。 尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。 还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。 这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。 总结synchronized的执行过程:? 上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作; 在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们; 偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁; 如果线程争用激烈,那么应该禁用偏向锁。 锁优化建议虽然对于以上介绍的锁都不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作。 减少锁的时间不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放。 减少锁的粒度它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间。 锁粗化大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度。 使用读写锁ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。 读写分离CopyOnWriteArrayList 、CopyOnWriteArraySet? 使用CAS如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用CAS效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择; 消除缓存行的伪共享除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。? sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;? volatile关键字volatile是Java中的轻量级同步机制,使用volatile可以保持内存可见性和防止指令重排序。 保持内存可见性内存可见性是指所有线程都能看到共享内存的最新状态。 Java内存模型在Java内存模型中,分为栈内存(线程私有)和堆内存(线程共享),Java中的内存模型依赖于硬件的存储模型。 Java内存模型和硬件存储模型的大致关系如下图所示(图片来源于网络): 而对于多个线程共享的变量,Java内存模型规定,变量存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),线程只能访问自己的工作内存,不可以访问其它线程的工作内存。 工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。 如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,定义了8种原子操作: (1) lock:将主内存中的变量锁定,为一个线程所独占 (2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量 (3) read:将主内存中的变量值读到工作内存当中 (4) load:将read读取的值保存到工作内存中的变量副本中 (5) use:将值传递给线程的代码执行引擎 (6) assign:将执行引擎处理返回的值重新赋值给变量副本 (7) store:将变量副本的值存储到主内存中 (8) write:将store存储的值写入到主内存的共享变量当中 大致过程如下图所示(图片来源于网络): 可见性带来的数据失效问题通过上面Java内存模型的描述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。即其他线程的本地内存中的变量已经是过时的,并不是更新后的值,也就是说其他线程本地内存中保持的是失效数据。 比如如下代码是一个可变整数类: MutableInteger.java public class MutableInteger { private int value; public int get() { return value; } public void set(int value) { this.value = value; } }
要解决这个问题其实也很简单,只需要将 private volatile int value; volatile如何保持内存可见性volatile的特殊规则就是:
所以,使用volatile变量能够保证:
也就是说,使用volatile关键字修饰的变量看到的随时都是自己的最新值。线程1中对变量value的最新修改,对线程2是可见的。这是因为每当修改本地内存变量的值时,在将更新同步到主内存的同时还会根据MESI清除其他线程中本地变量副本值,迫使变量副本重新同步为主内存的最新值。 防止指令重排指令重排序指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序员带来问题。 不同的指令间可能存在数据依赖。比如下面计算圆的面积的语句: double r = 2.5d; //(1) double pi =3.1415926; //(2) double area = pi* r * r; //(3) area的计算依赖于r与pi两个变量的赋值指令。而r与pi无依赖关系。 as-if-serial语义是指:不管如何重排序(编译器与处理器为了提高并行度),(单线程)程序的结果不能被改变。这是编译器、Runtime、处理器必须遵守的语义。 虽然,(1) - happensbefore -> (2),(2) - happens before -> (3),但是计算顺序(1)(2)(3)与(2)(1)(3) 对于r、pi、area变量的结果并无区别。编译器、Runtime在优化时可以根据情况重排序(1)与(2),而丝毫不影响程序的结果。 指令重排序包括编译器重排序和运行时重排序。 指令重排序带来的问题例子1:A线程指令重排导致B线程出错对于在同一个线程内,这样的改变是不会对逻辑产生影响的,但是在多线程的情况下,指令重排序会带来一些问题。 我们来看下面这个情景: 在线程A中: context = loadContext(); init = true; 在线程B中: while(!init) { // 根据线程A中对init变量的修改决定是否使用context变量 sleep(1000); } doSomethingWithConfig(context); 假设此时在线程A中发生了指令重排序: init = true; context = loadContext(); 那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。 例子2:指令重排导致单例模式失效如下,是一个懒加载的单例模式,在单线程中这个单例是没有问题的,但是在多线程中, class Singleton { 为了解决这个问题,可以使用 class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { // 当instance不为null时,仍可能指向一个“被部分初始化的对象” synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } } “看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个 问题出在这行简单的赋值语句: instance = new Singleton();
它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令: memory = allocate(); // 1:分配对象的内存空间 initInstance(memory); // 2:初始化对象 instance = memory; // 3:设置instance指向刚分配的内存地址 上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行 memory = allocate(); //1:分配对象的内存空间 instance = memory; //3:设置instance指向刚分配的内存地址(此时对象还未初始化) ctorInstance(memory); //2:初始化对象 可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,而这段崭新的内存还没有初始化,即引用instance指向了一个"被部分初始化的对象"。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。 解决这个问题也很简单,只需要将instance声明为volatile变量即可。 private static volatile Singleton instance; volatile如何防止指令重排volatile关键字是通过提供“内存屏障”的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 大多数的处理器都支持内存屏障的指令。但对于编译器来说,发现一个最优布置来最小化插入屏障的总数是不太可能的,为此,Java内存模型采取了保守的策略。 下面是基于保守策略的JMM内存屏障插入策略:
注意要点1.volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatilei变量是一种比synchronized关键字更轻量级的同步机制。 2.由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。 3.加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。 4.在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。 Volatile与Synchronized的区别1、volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;并且volatile只能修饰变量,而synchronized可以修饰方法,以及代码块。 2、从内存可见性角度看,volatile读相当于加锁,volatile写相当于解锁。 3、synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。 4、关键字volatile解决的是变量在多个线程之间的可见性问题,而synchronized关键字解决的是多个线程之间访问资源的同步性。 sleep和wait的区别1、sleep是Thread的静态方法、wait是Object的方法; sleep方法属于Thread类中方法,表示让一个线程进入睡眠状态,等待一定的时间之后,自动醒来进入到可运行状态,不会马上进入运行状态,因为线程调度机制恢复线程的运行也需要时间,一个线程对象调用了sleep方法之后,并不会释放他所持有的所有对象锁,所以也就不会影响其他进程对象的运行。但在sleep的过程中有可能被其他对象调用它的interrupt(),产生InterruptedException异常,如果你的程序不捕获这个异常,线程就会异常终止,进入TERMINATED状态,如果你的程序捕获了这个异常,那么程序就会继续执行catch语句块(可能还有finally语句块)以及以后的代码。 wait属于Object的成员方法,一旦一个对象调用了wait方法,必须要采用notify()或notifyAll()方法唤醒该进程;如果线程拥有某个或某些对象的同步锁,那么在调用了wait()后,这个线程就会释放它持有的所有同步资源,而不限于这个被调用了wait()方法的对象。wait()方法也同样会在wait的过程中有可能被其他对象调用interrupt()方法而产生InterruptedException异常。 其实两者都可以让线程暂停一段时间,但是本质的区别是sleep是线程的运行状态控制,wait是线程之间的通讯问题。sleep()是让某个线程暂停运行一段时间,其控制范围是由当前线程决定,也就是说,在线程里面决定。好比如说,我要做的事情是"点火->烧水->煮面",而当我点完火之后我不立即烧水,我要休息一段时间再烧。对于运行的主动权是由我的流程来控制。而wait(),首先,这是由某个确定的对象来调用的,将这个对象理解成一个传话的人,当这个人在某个线程里面说"暂停!",也是 thisOBJ.wait(),这里的暂停是阻塞。还是"点火->烧水->煮饭",thisOBJ就好比一个监督我的人站在我旁边,本来该线程应该执行1后执行2,再执行3,而在2处被那个对象喊暂停,那么我就会一直等在这里而不执行3,但正个流程并没有结束,我一直想去煮饭,但还没被允许,直到那个对象在某个地方说"通知暂停的线程启动!",也就是thisOBJ.notify()的时候,那么我就可以煮饭了,这个被暂停的线程就会从暂停处继续执行。 ? 源码下载码云:https://gitee.com/liuge1988/java-demo.git 作者:朝雨忆轻尘 (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |