Java线程安全与锁优化

Java线程

Java线程的实现

在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。对于Sun JDK来说,它的windows版和Linux版都是使用一对一的线程模型来实现的,一条Java线程映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型是一对一的。

Java线程调度

Java线程调度是指系统为线程分配处理器使用权的过程,主要有两种调度方式:抢占式和协同式。
协同式调度的多线程系统,线程的执行时间由线程本身控制,线程把自己的工作执行完了之后,主动通知系统切换到另一个线程上。
抢占式调度的多线程系统,每个线程由系统来分配执行时间,线程的切换不由线程本身来决定。(在Java中,可以通过Thread.yield()来让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)Java使用的线程调度方式就是抢占式。

Java线程状态转化

Java语言定义了5种线程状态,一条线程在任意一个时间点只能有一种状态。

  • 新建(New):创建后尚未启动的线程处于这种状态
  • 运行(Runable):Runable包括了操作系统状态中的Running和Ready,也就是说处于这种状态的线程,可能正在执行,也可能正在等待cpu为之分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被cpu分配执行时间,他们要等待被其他线程显示的唤醒。以下方法会让线程陷入无限期等待中:
    没有设置Timeout参数的Object.wait()方法
    没有设置Timeout参数的Thread.join()方法
    LockSupport.park()方法。
  • 限期等待(Timed waiting)处于这种状态的线程也不会被cup分配执行时间,不过无须等待被其他线程显示唤醒,在一定时间之后它们由系统自动唤醒。以下方法会让线程进入限期等待状态
    Thread.sleep()方法
    设置了Timeout参数的Object.wait()方法
    设置了Timeout参数的Thread.join()方法
    LockSupport.parkNanos()方法
    LockSupport.parkUtil()方法
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”和“等待状态”的区别:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另一个线程中放弃这个锁的时候发生,而“等待状态”则是等待一段时间或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
    线程转换关系如下图:
    Java线程安全与锁优化_第1张图片

线程安全与锁优化

线程安全

Brian Goetz对“线程安全”的定义为:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是安全的。
Java语言中按照线程安全的“安全程度“由强到弱来排序,可以将Java语言中的各种操作共享数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

  • 不可变:在Jdk1.5之后的Java语言中,不可变的对象一定是线程安全的。无论是对象方法的实现还是方法的调用者,都不需要再采取任何线程安全保障措施。
  • 绝对线程安全:绝对线程安全满足Brian Goetz给出的线程安全的定义。在Java API中标注自己是线程安全的类,大多不是绝对线程安全的。
  • 相对线程安全:相对线程安全是我们通常意义上所说的线程安全。它需要保证对这个对象单独的操作是线程安全的,我们在调用时不需要额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。在Java中,大部门线程安全类都属于这种类型:Vector、HashTable、Collections的SynchronizedCollection()方法包装的集合等。
  • 线程兼容:线程兼容是指对象本身不是线程安全的,但是我们可以在调用端使用正确的同步手段来保证在并发环境下安全的使用。如ArrayList、HashMap等。
  • 线程对立:线程对立是无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的。

线程安全的实现方法

  • 互斥同步

互斥同步是常见的一种并发正确性保障手段。同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或多个,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面:互斥是因,同步是果;互斥是方法,同步是目的。
在Java中,最基本的互斥同步手段是synchronized关键字,synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中synchronized明确指定了对象参数,那就是这个对象的reference。如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
根据虚拟机规范的要求,在执行monitorentor指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1。当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成。这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。
除了synchronized之外,我们还可以使用java.util.concurrent包中的重入锁ReentrantLock来实现同步,在基本用法上,ReentrantLock于synchronized相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/catch语句块来完成),另外一个表现为原生语法层面的互斥锁。不过,相比synchronized,ReetrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。
等待可中断:等待可中断实施指当持有锁的线程长时间不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized是非公平的,而ReentrantLock默认情况也是非公平的,但可以通过带boolean值得构造函数要求使用公平锁。
锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。

  • 非阻塞同步
    互斥同步最主要的问题是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。从处理问题的方式上来说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态的转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补救措施(最常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略许多实现都不需要把线程挂起,因此这种同步操作称之为非阻塞同步。
    乐观并安策略需要操作和冲突检测这两个步骤具备原子性。硬件保证一个从语义上看起来需要多次操作的行为之通过一条处理器指令就能完成。这类指令常用的有
    测试并设置
    获取并增加
    交换
    比较并交换(CAS)
    加载链接/条件存储

CAS需要有三个操作数,分别是内存位置(V),旧的预期值(A)和新值(B)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值。否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。CAS操作在JDK1.5之后才能使用,该操作有sun.misc.Unsafe类里面的compareAndSwapInt和compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法进行了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令。由于Unsafe类不是提供给用户程序调用的类,因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。
AtomicInteger类的incrementAndGet()方法的实现如下:

public final int incrementAndGet(){
for(;;){
int current=get();
int next=current+1;
if(compareAndSet(current,next))
return next;
}
}

CAS操作无法涵盖互斥同步的所有使用场景,存在这样一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值是仍是A值,如果这段时间他的值被修改成了B值,后来又被改回A值,那么CAS操作会误认为它从来没有被改变过。这个漏洞被称为CAS操作的“ABA”问题,J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类"AtomicStampedReference",他可以通过控制变量值的版本来保证CAS的正确性。

  • 无同步方案
    如果一个方法本来就不涉及共享数据,那它自然就无须任何同步的措施去保证正确性,因此有一些代码天生就是线程安全的。如下:
    可重入代码:这种代码也叫纯代码,可以在代码执行的任何时刻去中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。可重入代码有一些共同的特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态变量都由参数中传入、不调用非可重入方法等。
    线程本地存储:如果能保证共享数据的代码在同一个线程中执行,这样无须同步也能保证线程之间不出现数据争用问题。符合这种特点的有:大部分使用消息队列的架构模式(如生产者-消费者)都会将产品的消费过程尽量在一个线程中消费完。
    以及经典Web交换模型“一个请求对应一个服务器线程”。
    在Java语言中,如果一个变量被多线程访问,可以使用volatile关键字声明它为“易变的”,如果一个变量被线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

锁优化

高效并发是JDK1.5到1.6的一个重要改进,HotSpot虚拟机实现了各种锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。

  • 自旋锁与自适应自旋
    在互斥同步中,对性能影响最大的就是阻塞了。挂起线程和恢复线程需要转入内核态才能完成。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并发执行,我们就可以让后面请求锁那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为力让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
    自旋锁在JDK1.4.2中引入,但是默认是关闭的,在1.6中默认开启。自旋不会放弃处理器的时间,如果锁占用的时间很长,那么自旋的线程会白白浪费处理器的资源,所以自旋等待的时间要有一定的限度,自旋默认的次数是10次,可以使用参数 -XX:PreBlockSpin来更改。
    在jdk1.6中引入了自适应自旋锁。自适应意味着自旋的时间不再固定了,而是有前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果同一个锁上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能成功,进而允许自旋等待更长的时间。另外,如果对于某个锁,自旋很少成功过,那在以后要获取这个锁时将可能省略掉自旋这个过程。
  • 锁消除
    锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持。如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
  • 锁粗化
    如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会 导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。
  • 轻量级锁
    轻量级锁是Jdk1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言,因此传统级锁称为”重量锁“。轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
    HotSpot虚拟机的对象头的内存布局对轻量级锁的理解至关重要。Hotspot虚拟机的对象头分为两部分信息:第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位虚拟机中分别占32bit和64bit,官方称为”Mark word“,它是实现轻量级锁的关键。另一部分用于存储指向方法区对象类型数据的指针。如果为数组对象,还存储数组对象的长度。
    对象头信息是与对象自身定义的数据无关的额外存储成本,Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的信息,它会根据对象的状态复用自己的存储空间。在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中的25bit用于存储对象的哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象存储内容如下表
存储内容 标志位 状态
对象hash码、对象分代年龄 01 未锁定
指向锁记录指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

轻量级锁的执行过程:在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为”01“状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象的Mark Word的锁标志位(Mark Word的最后2bit)将转变为”00“,即表示为此对象处于轻量级锁定状态。如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值 变为”10“,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁的解锁过程也是通过CAS操作来进行的。如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试获取过资源,那就要在释放的同事,唤醒被挂起的线程。
轻量级锁能提升程序同步性能的依据是”对于绝大部分的锁,在整个同步期间内都是不存在竞争的”,这是一个经验数据,如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

  • 偏向锁
    偏向锁也是jdk1.6中引入的一项优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS都不用做了。偏向锁意味着这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
    偏向锁的原理:假设当前的虚拟机启用了偏向锁(启用参数:-XX:+UseBiasedLocking,这是Jdk1.6的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设为"01",即偏向模式。同时使用CAS操作把获取到这个锁的线程Id记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定状态。后续的同步操作就如上面介绍的轻量级锁那样执行。偏向锁、轻量级锁的状态转换及对象的Mark Word的关系如图所示。
    Java线程安全与锁优化_第2张图片

偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。

你可能感兴趣的:(java,虚拟机,java,多线程,安全,面试)