读书笔记 ---- 《深入理解Java虚拟机》---- 第12篇:线程安全与锁优化

上一篇:Java内存模型与线程:https://blog.csdn.net/pcwl1206/article/details/84661639

目  录:

1  Java语言中的线程安全 

1.1  不可变 

1.2  绝对线程安全

1.3  相对线程安全

1.4  线程兼容

1.5  线程对立

2  线程安全的实现方法

2.1  互斥同步   --  悲观锁

2.2  非阻塞同步   --  乐观锁

2.3  无同步方案

3  锁优化

3.1  自旋锁和自适应自旋

3.2  锁消除

3.3  锁粗化

3.4  轻量级锁

3.5  偏向锁

4  总结


如何实现“高效并发”:首先保证并发的正确性,再在此基础上实现高效。

线程安全的定义:

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

1  Java语言中的线程安全 

按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容线程对立

1.1  不可变 

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

Java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何的影响,可以将其带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的了。

常见的不可变类有:String、枚举类、java.lang.Number的部分子类,如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。

1.2  绝对线程安全

在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。例如java.util.Vector是一个线程安全的容器,即使它的add()、get()和size()这类方法都被synchronized修饰,也不意味着调用它的时候永远都不再需要同步手段了。下面举例说明:

import java.util.Vector;
 
public class Demo {
	
	private static Vector<Integer> vector = new Vector<Integer>();
	public static void main(String[] args) throws Throwable{
		while(true){
			for (int i = 0; i < 10; 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.println(vector.get(i));
					}
					
				}
				
			});
			removeThread.start();
			printThread.start();
			// 不要同时产生过多的线程,否则会导致操作系统假死
			while (Thread.activeCount() > 20);
		}
	}
}

运行结果会抛出:数组下标越界错误。

如果一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException,如果要保证上面这段代码正常的运行下去,需要加入同步以保证Vector访问的线程安全。

 
import java.util.Vector;
 
public class Demo {
 
	private static Vector<Integer> vector = new Vector<Integer>();
	public static void main(String[] args) throws Throwable{
		while(true){
			for (int i = 0; i < 10; i++){
				vector.add(i);
			}

			Thread removeThread = new Thread(new Runnable(){
				public void run() {
					synchronized (vector) {
						for (int i = 0; i < vector.size(); i++){
							vector.remove(i);
						}
					}
				}
			});

			Thread printThread = new Thread(new Runnable(){
				public void run() {
					synchronized (vector) {
						for (int i = 0; i < vector.size(); i++){
							System.out.println(vector.get(i));
						}
					}
				}
			});

			removeThread.start();
			printThread.start();
			// 不要同时产生过多的线程,否则会导致操作系统假死
			while (Thread.activeCount()>20);
		}
	}
}

1.3  相对线程安全

相对的线程安全需要保证这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。比如上面的vector案例中的代码。

在Java语言中,大部分的线程安全类都属于这种类型,例如:Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

1.4  线程兼容

对象身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,例如:Vector和HashTable相对应的集合类ArrayList和HashMap等。

1.5  线程对立

不管采用什么同步措施都不能保证线程安全。例如Thread类的suspend() 和resume()方法,但是已经被JDK废弃了。


2  线程安全的实现方法

虚拟机提供的同步和锁机制在线程安全方面发挥了很大的作用。

2.1  互斥同步   --  悲观锁

互斥同步(Mutual  Exclusion & Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)、和信息量(Semaphore)都是主要的互斥实现方式。互斥是因,同步是果,互斥是方法,同步是目的。

最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译后,会在同步块的前后分别形成monitorentermonitorexit这个两个字节码指令,这个两个字节码需要一个reference类型的参数来指明要锁定和解锁的对象,获取对象的锁,锁计数器加1,反之减一,当计数器为0时,锁就被释放了。

重入锁(ReentrantLock)来实现同步。代码写法上有点区别,一个表现为API层面的互斥锁(lock())和unlock()方法配合try/finally语句块来完成,一个表现为原生语法层面的互斥锁,不过ReentrantLock比synchronized增加了一些高级功能,主要有以下:等待可中断、可实现公平锁,以及锁可以绑定多个条件。

互斥同步最主要的问题是进行线程阻塞和唤醒所带类的性能问题,因此这种同步也称为阻塞同步。从处理问题的角度来说,互斥同步属于一种悲观的并发策略,总认为只要不去做正确的同步措施(如:加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都会进行加锁、用户核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

2.2  非阻塞同步   --  乐观锁

先进行操作,如果没有其他线程竞争用共享数据,那操作就成功了;如果有共享数据被争用,产生了冲突,那就再采取其他的补偿措施,最常见的补偿措施就是不断地重试,直到成功为止。这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

硬件需要保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类的指令常有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap   CAS)
  • 加载链接/条件存储(Load-Linked/Store-Conditional   LL/SC)

CAS:

CAS需要3个操作数,分别是内存位置V、旧的预期值A和新值B。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上诉的过程是一个原子操作。

import java.util.concurrent.atomic.AtomicInteger;
 
public class AtomicTest {
 
	public static AtomicInteger race = new AtomicInteger(0);
	
	public static void increase(){
		race.incrementAndGet();
		System.out.println(race);
	}
	private static final int THREADS_COUNT= 20;
	
	public static void main(String[] args) {
		Thread[] threads=  new Thread[THREADS_COUNT];
		for (int i = 0; i < THREADS_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);
    }
}

运行结果:200000

CAS会出现ABA问题,虽然一线程访问前是A,访问后还是A,但是在此期间可能有线程改为B,然后又改成了A, 为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”。

2.3  无同步方案

不需要进行同步操作。

2.3.1  可重入代码

在代码执行过程中可以中断它,转而去执行另一段代码,而控制权返回后,原来的程序不会出现任何错误。

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

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

2.3.2  线程本地存储

一段代码中所需要的数据必须与其他代码共享,但是这些共享数据的代码在同一个线程中,则无需同步也可以保证线程安全。

比如:生产者—消费者模式、Web交互模式(一个请求对应一个服务器线程)。

可以通过java.lang.ThreadLocal类来实现线程本地的存储功能。


3  锁优化

锁优化技术:适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)。

3.1  自旋锁和自适应自旋

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

-XX:+UseSpinning 参数开启,自旋就是一直在运行(等待线程释放锁,这样避免唤醒和休眠的性能损耗),只是在空转,自旋次数的默认是10次,如果自旋10次没有等待线程释放锁就挂起线程,用户使用参数-XX:PreBlockSpin设置自旋次数。自适应自旋根据近期自旋是否获得过锁的情况自适应的进行自旋,避免锁占用的时间很长,自旋等待的时间太长,白白浪费处理器资源。

3.2  锁消除

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

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

3.3  锁粗化

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

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。则可以把锁的同步范围扩展(粗化)到整个操作序列的外部。

3.4  轻量级锁

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

相对传统同步(重量级锁)来说,它是通过对象头部有标记信息(Mark Word) 来标记当前的锁情况,主要分为五种:未锁定(01)、轻量级锁(00)、重量级锁(10)、GC标记(11) 、可偏向(01)

类似于:刚开始是道德约束(对应轻量级锁)、如果道德约束不行,就法律约束(重量级锁),刚开始获取这个对象资源采用轻量级锁,当有线程来竞争,而当前线程占用,这时候就改变标记为10(重量级锁),你必须等着我执行完才能执行。【--引用自:https://blog.csdn.net/m0_37355951/article/details/77750182

3.5  偏向锁

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

偏向锁的“偏”字就是说:这个锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将应选不需要再进行同步。

启用偏向锁-XX:+UseBiaseLocking,就是第一个线程获取执行,如果有线程来竞争,就取消标记,改为轻量级锁,一直到重量级锁。


4  总结

本文的主要内容:

1、线程安全所涉及的概念和分类;

2、同步实现的方式及虚拟机的底层运作原理;

3、虚拟机为了实现高效并发所采用的一系列锁优化措施。


上一篇:Java内存模型与线程:https://blog.csdn.net/pcwl1206/article/details/84661639

猜你喜欢

转载自blog.csdn.net/pcwl1206/article/details/84674792