Java线程安全与多线程开发

互联网上充斥着对Java多线程编程的介绍,每篇文章都从不同的角度介绍并总结了该领域的内容。但大部分文章都没有说明多线程的实现本质,没能让开发者真正“过瘾”。

从Java的线程安全鼻祖内置锁介绍开始,让你了解内置锁的实现逻辑和原理以及引发的性能问题,接着说明了Java多线程编程中锁的存在是为了保障共享变量的线程安全使用。下面让我们进入正题。

以下内容如无特殊说明均指代Java环境。

第一部分:锁

提到并发编程,大多数Java工程师的第一反应都是synchronized关键字。这是Java在1.0时代的产物,至今仍然应用于很多的项目中,伴随着Java的版本更新已经存在了20多年。在如此之长的生命周期中,synchronized内部也在进行着“自我”进化。

早期的synchronized关键字是Java并发问题的唯一解决方案, 伴随引入这种“重量型”锁,带来的性能开销也是很大的,早期的工程师为了解决性能开销问题,想出了很多解决方案(例如DCL)来提升性能。好在Java1.6提供了锁的状态升级来解决这种性能消耗。一般通俗的说Java的锁按照类别可以分为类锁和对象锁两种,两种锁之间是互不影响的,下面我们一起看下这两种锁的具体含义。

类锁和对象锁

由于JVM内存对象中需要对两种资源进行协同以保证线程安全,JVM堆中的实例对象和保存在方法区中的类变量。因此Java的内置锁分为类锁和对象锁两种实现方式实现。前面已经提到类锁和对象锁是相互隔离的两种锁,它们之间不存在相互的直接影响,以不同方式实现对共享对象的线程安全访问。下面根据两种锁的隔离方式做如下说明:

1、当有两个(或以上)线程共同去访问一个Object共享对象时,同一时刻只有一个线程可以访问该对象的synchronized(this)同步方法(或同步代码块),也就是说,同一时刻,只能有一个线程能够得到CPU的执行,另一个线程必须等待当前获得CPU执行的线程完成之后才有机会获取该共享对象的锁。

2、当一个线程已经获得该Object对象的同步方法(或同步代码块)的执行权限时,其他的线程仍然可以访问该对象的非synchronized方法。

3、当一个线程已经获取该Object对象的synchronized(this)同步方法(或代码块)的锁时,该对象被类锁修饰的同步方法(或代码块)仍然可以被其他线程在同一CPU周期内获取,两种锁不存在资源竞争情况。

在我们对内置锁的类别有了基本了解后,我们可能会想JVM是如何实现和保存内置锁的状态的,其实JVM是将锁的信息保存在Java对象的对象头中。首先我们看下Java的对象头是怎么回事。

Java对象头

为了解决早期synchronized关键字带来的锁性能开销问题,从Java1.6开始引入了锁状态的升级方式用以减轻1.0时代锁带来的性能消耗,对象的锁由无锁状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁状的升级。

图1.1:对象头

在Hotspot虚拟机中对象头分为两个部分(数组还要多一部分用于存储数组长度),其中一部分用来存储运行时数据,如HashCode、GC分代信息、锁标志位,这部分内容又被称为Mark Word。在虚拟机运行期间,JVM为了节省存储成本会对Mark Word的存储区间进行重用,因此Mark Word的信息会随着锁状态变化而改变。另外一部分用于方法区的数据类型指针存储。

Java的内置锁的状态升级实现是通过替换对象头中的Mark Word的标识来实现的,下面具体看下内置锁的状态是如何从无锁状态升级为重量级锁状态。

内置锁的状态升级

JVM为了提升锁的性能,共提供了四种量级的锁。级别从低到高分为:无状态的锁、偏向锁、轻量级的锁和重量级的锁。在Java应用中加锁大多使用的是对象锁,对象锁随着线程竞争的加剧,最终可能会升级为重量级的锁。锁可以升级但不能降级(也就是为什么我们进行任何基准测试都需要对数据进行预热,以防止噪声的干扰,当然噪声还可能是其他原因)。在说明内置锁状态升级之前,先介绍一个重要的锁概念,自旋锁。

自旋锁

在互斥(mutex)状态下的内置锁带来的性能下降是很明显的。没有得到锁的线程需要等待持有锁的线程释放锁才可以争抢运行,挂起和恢复一个线程的操作都需要从操作系统的用户态转到内核态来完成。然而CPU为保障每个线程都能得到运行,分配的时间片是有限的,每次上下文切换都是非常浪费CPU的时间片的,在这种条件下自旋锁发挥了优势。

所谓自旋,就是让没有得到锁的线程自己运行一段时间,线程自旋是不会引起线程休眠的(自旋会一直占用CPU资源),所以并不是真正的阻塞。当线程状态被其他线程改变才会进入临界区,进而被阻塞。在Java1.6版本已经默认开启了该设置(可以通过JVM参数-XX:+UseSpinning开启,在Java1.7中自旋锁的参数已经被取消,不再支持用户配置而是虚拟机总会默认执行)。

虽然自旋锁不会引起线程的休眠,减少了等待时间,但自旋锁也存在着对CPU资源浪费的情况,自旋锁需要在运行期间空转CPU的资源。只有当自旋等待的时间高于同步阻塞时才有意义。因此JVM限制了自旋的时间限度,当超过这个限度时,线程就会被挂起。

在Java1.6 中提供了自适应自旋锁,优化了原自旋锁限度的次数问题,改为由自旋线程时间和锁的状态来确定。例如,如果一个线程刚刚自旋成功获取到锁,那么下次获取锁的可能性就会很大,所以JVM准许自旋的时间相对较长,反之,自旋的时间就会很短或者忽略自旋过程,这种情况在Java1.7也得到了优化。

自旋锁是贯穿内置锁状态始终的,作为偏向锁,轻量级锁以及重量级锁的补充。

偏向锁

偏向锁是Java1.6 提出的一种锁优化机制,其核心思想是,如果当前线程没有竞争则取消之前已经取得锁的线程同步操作,在JVM的虚拟机模型中减少对锁的检测。也就是说如果某个线程取得对象的偏向锁,那么当这个线程在此请求该偏向锁时,就不需要额外的同步操作了。

具体的实现为当一个线程访问同步块时会在对象头的Mark Word中存储锁的偏向线程ID,后续该线程访问该锁时,就可以简单的检查下Mark Word是否为偏向锁并且其偏向锁是否指向当前线程。

如果测试成功则线程获取到偏向锁,如果测试失败,则需要检测下Mark Word中偏向锁的标记是否设置成了偏向状态(标记位为1)。如果没有设置,则使用CAS竞争锁。如果设置了,尝试使用CAS将对象头的Mark Word偏向锁标记指向当前线程。也可以使用JVM参数-XX:-UseBiastedLocking参数来禁用偏向锁。

因为偏向锁使用的是存在竞争才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

轻量级的锁

如果偏向锁获取失败,那么JVM会尝试使用轻量级锁,带来一次锁的升级。轻量级锁存在的出发点是为了优化锁的获取方式,在不存在多线程竞争的前提下,以减少Java 1.0时代锁互斥带来的性能开销。轻量级锁在JVM内部是使用BasicObjectLock对象实现的。

其具体的实现为当前线程在进入同步代码块之前,会将BasicObjectLock对象放到Java的栈桢中,这个对象的内部是由BasicLock对象和该Java对象的指针组成的。然后当前线程尝试使用CAS替换对象头中的Mark Word锁标记指向该锁记录指针。如果成功则获取到锁,将对象的锁标记改为00 | locked,如果失败则表示存在其他线程竞争,当前线程使用自旋尝试获取锁。

当存在两条(或以上)的线程共同竞争一个锁时,此时的轻量级的锁将不再发挥作用,JVM会将其膨胀为重量级的锁,锁的标位为也会修改为10 | monitor 。

轻量级锁在解锁时,同样是通过CAS的置换对象头操作。如果成功,则表示成功获取到锁。如果失败,则说明该对象存在其他线程竞争,该锁会随着膨胀为重量级的锁。

重量级的锁

JVM在轻量级锁获取失败后,会使用重量级的锁来处理同步操作,此时对象的Mark Word标记为 10 | monitor,在重量级锁处理线程的调度中,被阻塞的线程会被系统挂起,在线程再次获得CPU资源后,需要进行系统上下文的切换才能得到CPU执行,此时效率会低很多。

通过上面的介绍我们了解了Java的内置锁升级策略,随着锁的每次升级带来的性能的下降,因此我们在程序设计时应该尽量避免锁的征用,可以使用集中式缓存来解决该问题。

一个小插曲:内置锁的继承

内置锁是可以被继承的,Java的内置锁在子类对父类同步方法进行方法覆盖时,其同步标志是可以被子类继承使用的,我们看下面的例子:

public class Parent { public synchronized void doSomething() { 
     System.out.println("parent do something"); 
}
public class Child extends Parent { public synchronized void doSomething() { 
.doSomething(); 
 public static void main(String[] args) { 
     new Child().doSomething(); 

代码1.1:内置锁继承

以上的代码可以正常的运行么?

答案是肯定的。

避免活跃度危险

Java并发的安全性和活跃度是相互影响的,我们使用锁来保障线程安全的同时,需要避免线程活跃度的风险。Java线程不能像数据库那样自动排查解除死锁,也无法从死锁中恢复。而且程序中死锁的检查有时候并不是显而易见的,必须到达相应的并发状态才会发生,这个问题往往给应用程序带来灾难性的结果,这里介绍以下几种活跃度危险:死锁、线程饥饿、弱响应性、活锁。

死锁

当一个线程永远的占有一个锁,而其他的线程尝试去获取这个锁时,这个线程将被永久的阻塞。

一个经典的例子就是AB锁问题,线程1获取到了共享数据A的锁,同时线程2获取到了共享数据B的锁,此时线程1想要去获取共享数据B的锁,线程2获取共享数据A的锁。如果用图的关系表示,那么这将是一个环路。这是死锁是最简单的形式。还有比如我们再对批量无序的数据做更新操作时,如果无序的行为引发了2个线程的资源争抢也会引发该问题,解决的途径就是排序后再进行处理。

线程饥饿

线程饥饿是指当线程访问它所需要的资源时却永久被拒绝,以至于不能再继续进行后面的流程,这样就发生了线程饥饿;例如线程对CPU时间片的竞争,Java中低优先级的线程引用不当等。虽然Java的API中对线程的优先级进行了定义,这仅仅是一种向CPU自我推荐的行为(此处需要注意不同操作系统的线程优先级并不统一,而且对应的Java线程优先级也不统一),但是这并不能保障高优先级的线程一定能够先被CPU选择执行。

弱响应性

在GUI的程序中,我们一般可见的客户端程序都是使用后台运行,前端反馈的形式,当CPU密集型后台任务与前台任务共同竞争资源时,有可能造成前端GUI冻结的效果,因此我们可以降低后台程序的优先级,尽可能的保障最佳的用户体验性。

活锁

线程活跃度失败的另一种体现是线程没有被阻塞,但是却不能继续,因为不断重试相同的操作,却总是失败。

线程的活跃度危险是我们在开发中应该避免的一种行为。这种行为会造成应用程序的灾难性后果。

总结

关于synchronized关键字的所有内容到这里全部介绍完毕了,在这一章节希望可以让大家明白锁之所以“重”是因为随着线程间竞争的程度升级导致的。在真正的开发中我们可能还有别的选择,例如Lock接口,在某些并发场景下性能优于内置锁的实现。

不论是通过内置锁还是通过Lock接口都是为了保障并发的安全性,并发环境一般需要考虑的问题是如何保障共享对象的安全访问。

转载自-- https://www.cnblogs.com/jurendage/p/8664026.html

原文地址:https://www.cnblogs.com/lu-manman/p/10052246.html

时间: 2024-11-08 19:25:02

Java线程安全与多线程开发的相关文章

Java 线程池和多线程编程 ——线程池理解与创建

JDK1.5 引入了 Executor框架 ,对任务提交和执行进行解耦 , 定义任务后交由线程池执行. 线程池是由java.util.concurrent 包中Executors类的工厂方法创建线程池. -------------------------------------------------------------------------------- 创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程. public static ExecutorService n

iOS多线程开发小demo6 GCD

// DYFViewController.m // 623-07-GCD // // Created by dyf on 14-6-23. // Copyright (c) 2014年 ___FULLUSERNAME___. All rights reserved. // #import "DYFViewController.h" @interface DYFViewController () @property (weak, nonatomic) IBOutlet UIImageVi

Java多线程开发系列之四:玩转多线程(线程的控制2)

在上节的线程控制(详情点击这里)中,我们讲解了线程的等待join().守护线程.本节我们将会把剩下的线程控制内容一并讲完,主要内容有线程的睡眠.让步.优先级.挂起和恢复.停止等. 废话不多说,我们直接进入正题:  3.线程睡眠  sleep() 所有介绍多线程开发的学习案例中,基本都有用到这个方法,这个方法的意思就是睡眠(是真的,请相信我...).好吧,如果你觉得不够具体,可以认为是让当前线程暂停一下,当前线程随之进入阻塞状态,当睡眠时间结束后,当前线程重新进入就绪状态,开始新一轮的抢占计划!

Java多线程开发系列之番外篇:事件派发线程---EventDispatchThread

事件派发线程是java Swing开发中重要的知识点,在安卓app开发中,也是非常重要的一点.今天我们在多线程开发中,穿插进来这个线程.分别从线程的来由.原理和使用方法三个方面来学习事件派发线程. 一.事件派发线程的前世今生 事件(Event)派发(Dispatch)线程(Thread)简写为EDT,也就是各个首字母的简写.在一些书或者博客里边也将其译为事件分发线程.事件调度线程.巴拉巴拉,总之,知道这些名字就行.笔者认为这里翻译成派发更准确点. 熟悉Swing和awt编程的小伙伴对事件派发线程

Java之多线程开发时多条件Condition接口的使用

转:http://blog.csdn.net/a352193394/article/details/39454157 我们在多线程开发中,可能会出现这种情况.就是一个线程需要另外一个线程满足某某条件才能继续运行,或者需 要其他线程满足好几个条件才能运行,对于这样的多条件的多线程并发,我们如何控制好各个线程之间的关系,使他们 能很好的处理冲突不至于相互出现问题呢,下面我们来介绍一下Java提供的Condition这个接口,这个接口很好的实现了 这种需求. 对于这个问题最经典的例子就是生产者消费者模

Java多线程开发之~~~多条件Condition接口的使用

我们在多线程开发中,可能会出现这种情况.就是一个线程需要另外一个线程满足某某条件才能继续运行,或者需 要其他线程满足好几个条件才能运行,对于这样的多条件的多线程并发,我们如何控制好各个线程之间的关系,使他们 能很好的处理冲突不至于相互出现问题呢,下面我们来介绍一下Java提供的Condition这个接口,这个接口很好的实现了 这种需求. 对于这个问题最经典的例子就是生产者消费者模型,生产者当缓冲区满的时候不生产商品知道缓冲区有空余,消费 者当缓冲区为0 的时候不拿商品,直到生产者向缓冲区放入商品

Java多线程开发系列之一:走进多线程

对编程语言的基础知识:分支.选择.循环.面向对象等基本概念后,我们需要对java高级编程有一定的学习,这里不可避免的要接触到多线程开发. 由于多线程开发整体的系统比较大,我会写一个系列的文章总结介绍 多线程开发的概念.使用.线程状态.同步.线程池.希望与大家共勉. 在第一部分,也就是本节我们先介绍下 什么是多线程程序.线程和进程又是什么,以及为什么要搞多线程. (一)什么是多线程程序 多线程听上去是非常专业的概念,其实非常简单,我们在日常生活中,经常的接触到多线程. 比如 (1)在工厂,工人努力

谈谈多线程开发中的线程和任务的理念

前段时间写了一个iOS端的数据统计SDK,数据统计有些复杂的计算和数据上报操作.由于有些操作比較耗时.所以不得不在后台线程进行操作,由此引发了我对多线程的思考,在iOS开发中,一般非常难再见到直接使用NSThread进行多线程编程的了.由于苹果提供了另外几种多线程开发的解决方式.而这些解决方式面向的不再是线程,而是面向的是任务,以下就来以iOS和Android为例简单谈谈的线程和任务的相关概念. 线程: 我们在学习基础的编程语言的时候,肯定听说过线程的概念,线程是操作系统调度的最小单位,共享进程

Java进阶(三)多线程开发关键技术

原创文章,转载请务必将下面这段话置于文章开头处. 本文转发自Jason's Blog,原文链接 http://www.jasongj.com/java/multi_thread/ sleep和wait到底什么区别 其实这个问题应该这么问--sleep和wait有什么相同点.因为这两个方法除了都能让当前线程暂停执行完,几乎没有其它相同点. wait方法是Object类的方法,这意味着所有的Java类都可以调用该方法.sleep方法是Thread类的静态方法. wait是在当前线程持有wait对象锁