线程安全与锁优化(笔记)

一、概述

面向过程的编程思想:把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度去抽象和解决问题,称为面向过程的编程思想。

面向对象的编程思想:站在现实世界的角度去抽象和解决问题,它把数据和行为都看做是对象的一部分。

二、线程安全

定义:“当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。”

线程安全的代码具备的特征:代码本身封装了所有的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。在大多数场景中,我们都会弱化这个定义,如果把“调用这个对象的行为”限定为“单词调用”,这个定义的其他描述也能够成立的话,我们就称它是线程安全了。

1、Java语言中的线程安全

我们这里讨论的线程安全,就限定于多个线程之间存在共享数据访问这个前提。

“安全程序”:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

1.1.不可变

A、特性:不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。

B、创建:

#基本数据类型:只需要在定义的时候使用final关键字修饰就可以保证它是不可变的。

#对象:需要保证对象的行为不会对其状态产生任何影响才行。最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后它就是不可变的。

C、Java不可变的类型:

String、枚举类型、Long和Double等数值包装类型。

1.2.绝对线程安全

A、定义:完全满足前面线程安全的定义。

B、代码演示

代码清单

public class Absolute {
	private static Vector<Integer> vector=new Vector<Integer>();
	public static void main(String[] args) throws InterruptedException{
		while(true){
			Thread.sleep(500);
			for(int i=0;i<8;i++){
				vector.add(i);
			}
			
			Thread removeThread = new Thread(new Runnable(){
				public void run() {
					for(int i=0;i<vector.size();i++){
						vector.remove(i);
					}
				}
			});	
			Thread printThread=new Thread(new Runnable(){
				public void run() {
					for(int i=0;i<vector.size();i++){
						System.out.print(vector.get(i)+" ");
					}
					System.out.println();
				}	
			});
			removeThread.start();
			printThread.start();
			while(Thread.activeCount()>20);
		}
	}
}

运行结果

运行结果并没有像书上所说会出错。在JDK8中应该对这方面的问题有所改进。如果另一个线程恰好在错误的时间内删除了一个元素,那么这时序号i所代表的的数值会变成0,而不是不可用。因此不会出现ArrayIndexOutOfBoundsException的错误。

1.3.相对线程安全

定义:就是我们通常意义上所讲的线程安全,它需要保证这个对象单独的操作时线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

1.4.线程兼容

定义:对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。

1.5.线程对立

定义:无论调用端是否采取了同步措施,都无法再多线程环境汇总并发使用代码。

例子:Thread类的suspend()方法和resume()方法。如果两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程。如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的。如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。

2、线程安全的实现方法

2.1.互斥同步——一种并发正确性保障手段

A、定义

“同步”定义:指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。

“互斥”定义:实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

互斥是因,同步是果。互斥是方法,同步是目的。

B、同步互斥实现的手段一——synchronized关键字

synchronized关键字。经过编译后会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中syncronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据syncronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

syncronized的特点:首先,syncronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

syncronized是Java语言中一个重量级的操作(需要用户系统帮忙,从用户态转到核心态),在确实必要的情况下再去使用这种操作。

B、同步互斥实现的手段二——java.util.concurrent包中的重入锁(ReentrantLock)

相同:ReentrantLock和syncronized都具备一样线程重入特性

不同:一个表现为API层面的互斥锁,另一个表现为原生语法层面的互斥锁

特性:

#等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。

#公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。而非公平锁则不保证这一点(syncronized中的锁就是非公平锁)

#锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象,而在syncronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐形的条件,如果要和多于一个的条件关联时,就不得不额外地添加一个锁。

C、两者的性能比较:JDK1.6以上的版本中两者性能差不多

1.2.非阻塞同步

A、互斥同步的问题:进行线程阻塞和唤醒带来的性能问题,因此这种同步也成为阻塞同步。

从出来问题的方式上说,互斥同步属于一种悲观的同步策略。总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论数据是否真的出现竞争,它都要进行加锁等操作。

B、改进策略——基于冲突检测的乐观并发策略

先进行操作,如果没有其它线程争用共享数据,那操作就成功了;如果共享数据由争用,产生了冲突,那就采取其它的补救措施(最常见的补救措施就是不断重试,直到成功为止)。

这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

这种策略需要靠硬件指令集来使操作和冲突检测这两个步骤具备原子性。硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:

#测试并设置

#获取并增加

#交换

#比较并交换(CAS)

#加载链接/条件存储(LL/SC)

C、JDK1.5之后Java程序中才可以使用CAS操作。

该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法进行了特殊处理,即使编译出来的结果就是一条平台相关的处理器指令,没有方法调用的过程,或者可以认为是无条件内联进去了。

由于UNsafe类不是提供给用户程序调用的类,因此如果不使用反射手段,我们只能通过其它的Java API来间接使用它。如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作

D、代码演示

import java.util.concurrent.atomic.AtomicInteger;
public class VolatileTest {
	public static AtomicInteger race = new AtomicInteger(0) ;
	
	public static void increase(){
		race.incrementAndGet();
	}
	
	private static final int THREAD_COUNT = 20;
	
	public static void main(String[] args){
		Thread[]  threads =new Thread[THREAD_COUNT];
		for(int i=0;i<THREAD_COUNT;i++){
			threads[i] =new Thread(new Runnable(){
				public void run() {
					for(int i = 0;i< 10000;i++){
						increase();
					}
				}
			});
			threads[i].start();
		}
		while(Thread.activeCount()>1)
			  Thread.yield();
		System.out.println(race);
	}
}

运行结果:

分析:incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环下一次操作,直到设置成功为止。

CAS存在的漏洞:“ABA”问题,但这个问题大部分情况下不会影响程序并发的正确性

1.3.无同步方案

要保证线程安全,并不是一定就要进行同步。

A、可重入代码:

定义:这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。

所有可重入的代码都是线程安全的,但是并非所有线程安全的代码都是可重入的。

特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

判断原则:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入行的要求,自然也就是线程安全的。

B、线程本地存储:

定义:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,那我们就可以把共享数据的可见范围限制在同一个线程之内,这样无需同步也能保证线程之间不出现数据争用的问题。

Java中如果一个变量要被某个线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象都有一个ThreadLocalMap对象。

三、锁优化

1、自旋锁与自适应自旋

1.1.背景:前面我们提到互斥同步对性能最大的影响就是阻塞的实现,挂起和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。

1.2.解决措施:如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程等待,但不放弃处理器的执行时间,让它进行一个忙循环(自旋)。

1.3.局限:自旋等待不能代替阻塞,且不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它本身是要占用处理器时间的,因此如果锁被占用的时间短,自旋效果就好。反之就只会白白浪费处理器资源。

1.4.改进——自适应的自旋锁:如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也有很大机会成功,就允许它进行更长时间的等待。反之,如果很少成功过,那就在以后要获取这个锁的时候将可能省略掉自旋过程。

2、锁消除

2.1.定义:值虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

2.2.主要判定依据:来源于逃逸分析的数据支持。如果判断值啊一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,自然无需进行同步加锁。

2.3.问题背景:有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也行超过了大部分读者的想象。

代码清单

public String concatString(String s1,String s2,String s3){
  return s1+s2+s3;
}

这一段代码看起来没有任何同步。但是我们知道String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接作自动优化。在JDK1.5之前会转化为StringBuffer对象的连续append()操作,在JDK1.5及之后的版本中,会转化为StringBuilder对象的连续append()操作。以上的代码可能变成如下代码

public String concatString(String s1,String s2,String s3){
  StringBuffer sb=new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}

StringBuffer的append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在concatString()方法内部。也就是其他线程无法访问到它,因此虽然这里有锁,但是可以被安全地消除掉,在即时编译后,这段代码就会忽略掉所有的同步而直接执行了。

3、锁粗化

3.1.一般情况:我们在编写代码的时候总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样做事为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待的线程也能尽快拿到锁。

3.2.特殊情况:如果一系列的连续操作都对同一个对象反复加锁额解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如代码清单中连续的append()操作就属于这种情况。

解决方案:如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。也就是第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

4、轻量级锁

4.1.定义:它名字中的”轻量级“是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为”重量级“锁。

4.2.目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

4.3.对象头信息中的存储锁状态

01表示未锁定;00表示轻量级锁定;10表示膨胀(重量级锁定);11表示GC标记

4.4.加锁

在代码进入同步块的时候,如果此对象没有被锁定(锁标志为为01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前Mark Word的拷贝。然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record 的指针,如果这个操作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁定状态。

如果这个操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前的线程,如果指向说明当前线程已经拥有这个对象的锁,那就可以直接进入同步块继续执行。否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。所标志状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。

4.5.解锁

如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。如果替换成功,那整个同步过程就完成了。如果同步失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

4.6.提升性能的依据:“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。

5、偏向锁

5.1.目的:消除数据在无竞争情况的同步,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

5.2.偏向锁的“偏”:它的意思是这个锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要在进行同步。

5.3.加锁(参数-XX:+UseBiaseLocking)

当锁对象第一次被线程获取的时候,虚拟机会把这个对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking等)

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据所对象目前是否处于被锁定的状态。撤销偏向后恢复到未锁定(“01”)或轻量级状态(“00”),后续的同步操作就如上面介绍的轻量级锁那样执行。

5.4.使用场景:偏向锁可以提高带有同步但无竞争的程序性能。

 

博客内容来自《深入理解Java虚拟机》。

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值