Java并发编程——其他基础知识

PunkLu 2020年01月12日 185次浏览
Java并发编程的其他基础知识

什么是多线程并发编程

并发

并发是指同一时间段内多个任务同时都在执行,并且都没有执行结束。

并行

并行是说在单位时间内多个任务同时在执行。

并发和并行的区别

并发任务强调在一个时间段内同时执行,而一个时间段由多个时间单位累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。在单CPU的时代多个任务都是并发执行的,这是因为单个CPU同时只能执行一个任务。在单CPU时代多任务是共享一个CPU的,当一个任务占用CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完后,会把CPU让给其他任务来使用,所以在单CPU时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。

为什么要进行多线程并发编程

多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个线程可以使用自己的CPU运行,这减少了线程上下文的切换。

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

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

内存模型

如上图,对于多核CPU架构来说,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操纵控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。那么Java内存模型里面的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器。

当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。那么假如线程A和线程B同时处理一个共享变量,假设线程A和线程B使用不同CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将会导致内存不可见问题。

线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值。假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache内和主内存里面的X的值都是1。

线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;因为这时候主内存中也是X=1。然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X的值为2,到这里一切都是好的。

线程A这时又要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,线程B已经把X的值修改为了2,但是因为缓存的存在,线程A获取到的还是1。这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

使用Java中的valatile关键字就可以解决这个问题。

Java中的synchronized关键字

synchronized介绍

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁使用,这些Java内置的使用者看不到的锁称为内部锁,也叫监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排他锁。

另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。

synchronized的内存语义

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

这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。

Java中的volatile关键字

使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,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 ThreadSafeInteger{
	
	private int value;
	
	public synchronized int get(){
		return value;
	}
	
	public synchronized void set(int value){
		this.value =  value;
	}
}

然后是使用volatile进行同步:

public class ThreadSafeInteger{
	
	private volatile int value;
	
	public int get(){
		return value;
	}
	
	public void set(int value){
		this.value = value;
	}
}

这里使用synchronized和使用volatile是等价的,都解决了共享变量内存可见性问题,但是前者存在线程上下文切换和线程重新调度的开销,而后者是非阻塞算法,不会造成线程上下文切换的开销。

但是volatile虽然提供了可见性保证,但并不像synchronized一样保证操作的原子性。

使用volatile关键字的情况:

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

Java指令重排序

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

使用volatile修饰变量就可以避免重排序和内存可见性问题。

写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

锁的概述

乐观锁与悲观锁

乐观锁与悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想。

悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。

乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排他锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。具体来说,根据update返回的行数让用户决定如何去做。

比如:

public int updateEntry(long id){
	// 1、使用乐观锁获取指定记录
	EntryObject entry = query("select * from table1 where id = #{id}",id);
	
	// 2、修改记录内容,version字段不能被修改
	String name = generatorName(entry);
	entry.setName(name);
	
	// 3、update操作
	int count = update("update table1 set name = #{name},age = #{age},version = ${version}+1 where id = #{id} and version=#{version}",entry);
	return count;
}

在如上代码中,先把记录查询出来,然后通过获取到的当前记录中的版本号version去尝试更新数据,如果更新行数大于0说明更新成功,否则说明已被其他线程更新。可以通过指定重试次数的方式再次尝试更新。

公平锁与非公平锁

公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则是先来不一定先得。

ReentrantLock提供了公平和非公平锁的实现。

  1. 公平锁

    ReentrantLock pairLock = new ReentrantLock(true);
    
  2. 非公平锁

    ReentrantLock pairLock = new ReentrantLock(false);
    

    如果构造函数不传递参数,则默认是非公平锁。

独占锁与共享锁

独占锁保证任何时候都只有一个线程能得到锁,ReentantLock就是以独占方式实现的。

共享锁则可以同时由多个线程持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作。

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

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

可重入锁

当一个线程再次获取他自己已经获取的锁时如果不被阻塞,则该锁是可重入的,也就是只要该线程获取了该锁,那么可以多次地进入被该锁锁住的代码。

synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器的值为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成1,这时其他线程再来获取该锁时会发现锁的持有者不是自己而被阻塞挂起。

但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值+1,当释放锁后计数器值-1。当计数器值为0时,锁里面的线程标示被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。

自旋锁

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

自旋锁是使用CPU时间换取线程阻塞与调度的开销。