05-Thread-java并发的其他基础知识

java中的线程安全问题

共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。

线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题,
脏数据(Dirty Read)是指源系统中的数据不在给定的范围内或对于实际业务毫无意义,或是数据格式非法,以及在源系统中存在不规范的编码和含糊的业务逻辑。

java中共享变量的内存可见性问题

在这里插入图片描述
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。

java内存模型中线程的本地内存(工作内存)对应CPU的L1,L2级缓存或寄存器;当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。那么这时候由于Cache的存在,将会导致内存不可见问题,

在Java中使用volatile可以解决内存不可见问题。

synchronized关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换

synchronized的内存语义

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。

volatile关键字

使用synchronized关键字,以锁的方式来解决内存的可见性问题,但这种方式太过于笨重,因为它会带来线程上下文的切换开销。对于内存可见性问题,Java还提供了一种弱形式的同步,就是使用volatile关键字。

该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

来看一个使用volatile关键字解决内存可见性问题的例子;

//这是线程不安全的例子
public class ThreadNotSafeInteger{
    
    
	private int value;
	public int get(){
    
    
	return value;
	}
	public void set(int value){
    
    
	 this.value =value;
	}
}
//使用synchronized关键字进行同步的方式
public class ThreadNotSafeInteger{
    
    
	private int value;
	public synchronized int get(){
    
    
	return value;
	}
	public synchronized void set(int value){
    
    
	 this.value =value;
	}
}
//这是使用volatile关键字进行同步的方式
public class ThreadNotSafeInteger{
    
    
	private  volatile int value;
	public int get(){
    
    
	return value;
	}
	public void set(int value){
    
    
	 this.value =value;
	}
}

在这里使用synchronized和使用volatile是等价的,都解决了共享变量value的内存可见性问题,但是前者是独占锁,同时只能有一个线程调用get()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。而后者是非阻塞算法,不会造成线程上下文切换的开销。

但并非在所有情况下使用它们都是等价的,volatile虽然提供了可见性保证,但并不保证操作的原子性。

在什么情况下使用volatile关键字?
1.写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
2. 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为volatile的。

java中的原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。在设计计数器时一般是先读取当前的值,然后+1,再更新。这过程是读-改-写的过程,如果不能保证这个过程是原子性的,那么就会出现线程安全问题。如下代码是线程不安全的,因为不能保证++value是原子性操作。

public class ThreadNotSafeCount{
    
    
	private Long value;

	public Long getCount(){
    
    
	return value;
	}

	public void inc(){
    
    
	++value;
}

}

如果使用Javap -c命令查看汇编代码,就可以发现++value是分为很多步操作共同完成的。因此,如++value最简单的语句,也不能保证原子性操作。

最简单的办法是使用synchronized关键字,可以保证原子性操作。

public class TreadSafeCount{
    
    
	private Long value;
	public synchronized Long getCount(){
    
    
	return value;
	}

	public synchronized void inc(){
    
    
	++value;
}

使用synchronized关键字的确可以实现线程安全性,即内存可见性和原子性,但是synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的getCount方法只是读操作,多个线程同时调用不会出现线程安全问题;但加了synchronzied关键字后,同一时间就只能有一个线程可以调用,这显然大大降低了并发性。你也许会问,既然只是读操作,那为何不去掉getCount方法上的synchronized关键字呢?其实是不能去掉的,别忘了这里要考sychronized来实现value的内存可见性。

在内部使用非阻塞CAS算法实现的AtomicLong来实现原子性操作,是一个不错的选择。

CAS操作

在Java中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个现场没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开销。Java提供了非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程序上弥补了锁带来的开销问题。但是volatile只能保证共享变量的可见性,不能解决读-改-写等的原子性问题。CAS是JDK提供的非阻塞原子性擦欧洲哦,它通过硬件保证了比较–更新操作的原子性。JDK里面的Unsafe类提供了一系列的comapreAndSwap方法。

使用CAS有时候也不能保证程序的正确执行,关于CAS操作有一个经典的ABA问题。

ABA问题是指在CAS操作时,其他线程将变量值A改为了B,但是又被改回了A,等到本线程使用期望值A与当前变量进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过,这与乐观锁的设计思想不符合。ABA问题的解决思路是,每次变量更新的时候把变量的版本号加1,那么A-B-A就会变成A1-B2-A3,只要变量被某一线程修改过,改变量对应的版本号就会发生递增变化,从而解决了ABA问题。

Unsafe类

JDK的rt.jar包中的Unsafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,它们使用JNI的方式访问本地C++实现库。

阅读素材:https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html

Java指令重排序

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖的指令重排序。在单线程下重排序可以保证最终执行的结果与程序执行的结果一致,但是在多线程下就会存在问题。

可以使用volatile关键字来避免指令重排序和内存可见性问题。

乐观锁和悲观锁

乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想。
悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理之前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会排他锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
悲观锁的实现往往依靠数据库提供的锁机制。
乐观锁并不会使用数据库提供的锁机制,一般会在表中添加version字段或者使用业务状态来实现;乐观锁直到提交时才锁定,所以不会产生任何死锁。

公平锁和非公平锁

根据现场获取锁的抢占机制,锁也可以分为公平锁和非公平锁,公平锁表示现场获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。
ReetrantLock提供额公平和非公平锁的实现。
公平锁:ReetrantLock fairlock = new ReetrantLock(true)
非公平锁:ReetrantLock fairlock = new ReetrantLock(false0. 如果构造函数不传,默认为非公平锁
在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

独占锁和共享锁

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只能有一个现场能得到锁,ReetrantLock就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如ReadWriteLock,它允许一个资源可以被多线程同时进行读操作。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它加宽了加锁的条件,允许多个线程同时进行读操作。

可重入锁

当一个线程要获取一个被其他现场持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们锁该锁是可被重入的,也就是说只要该线程获取了该锁,那么可以接近无数次地进入该被锁住的代码块。

来看一个例子,在什么情况下使用可重入锁。

public class Hello{
    
    
		public synchronized void helloA(){
    
    
		System.out.println("hello");
	}
	public synchronized void helloB(){
    
    
	System.out.println("hello B");
	helloA();
}
}

在上述代码中,调用helloB方法前会先获取内置锁,然后打印输出。之后调用helloA方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被阻塞。
实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成1,这时其他线程再来获取锁时会发现锁的所有者不是自己而被阻塞起挂起。

自旋锁

由于Java中的线程与操作系统中的线程一一对应,所以当一个现场在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而 从用户状态切换到内核状态的开销是很大的,在一定程序上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10,可以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间被白白浪费。

猜你喜欢

转载自blog.csdn.net/qq_41729287/article/details/113941606
今日推荐