Java并发学习总结

本文深入探讨Java并发编程的关键概念,包括线程间的可见性、原子性和有序性,详解volatile关键字的作用机制,以及如何利用synchronized、Lock接口和队列同步器实现线程安全。此外,还介绍了原子类、线程局部变量、不可变对象和各种线程安全的集合类,如ConcurrentHashMap、CopyOnWriteArrayList及BlockingQueue等。
摘要由CSDN通过智能技术生成

可见性

线程之间的可见性,一个线程修改的状态对另外的线程可见,即A线程改变了某个状态,B也能马上看到。

用volatile修饰的变量就是可见的,不允许线程内部缓存和重排序,即直接修改内存,如果不是用volatile的话,不同的线程会先从主存中copy一个对象,放在CPU缓存中,导致不同的线程修改变量,其他的线程看不到,不过volatile它不保证原子性,例如:

 volatile int a = 0;
 a++;

分为三步:

  1. 获取a的值;
  2. 计算a + 1的值;
  3. 给a赋值

之后所有线程都可以看到a的改变,但是还是有可能在第二步的时候B线程获取了a的值,还是0。所以不能保证线程安全性。那么volatitle如何保证可见性呢?

volatile 如何保证可见性

如下代码用volatile来修饰一个变量:

volatile Singleton instance = new Singleton();

上面这行代码转换成汇编代码之后:

0x01a3deld:movb $0×0,0×1104800(%esi);ox01a3de24:lock add1 $0×0,(%esp).

Lock前缀执行在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存;
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

然后每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里。

volatile 的使用优化

JDK中新增了一个队列集合类:LinkedTransferQueue,它在使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能。

将共享变量追加到64个字节,因为有些处理器缓存行是要填满的,如果不填满,就会将其他的补充,有一个处理器试图修改头结点时,会锁定整个缓存行,导致其他的处理器不能范围自己的尾节点。当然如果处理器的缓冲行不是64字节的,就不要采用这个方式了。

原子性

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。 比如a = 0:这个操作不可分割,a++:可以再进行拆分的非原子操作,需要先对a进行自增,再赋值,那么多线程时就会存在安全问题。

Java提供了一些原子类,例如:AtomicInteger、AtomicLong,AtomicReference等,可以用这些原子类来保证操作的原子性。

举例AtomicInteger是如何实现原子性操作,以下代码是AtomicInteger的自增代码:

public final int getAndIncrement(){
	for(;;){
	int current = get();  //获取当前的值
	int next = current + 1;		//获取current + 1的值
	if(compareAndSet(current,next)){  //判断当前值是否 == current.不是直接current 和 next。但是如果当前值被修改成别的,又修改成原来的,即ABA问题,它就意识不到了。可以通过Java引用解决。不常用。
		return current;
	  }
}
	public final boolean compareAndSet(int expect,int update){
	  return unsafe.compareAndSwapInt(this,valueOffset,expect,update);
	}
}

源码中for循环体的第一步先取得AtomicInteger里存储的数值,第二步对AtomicInteger的当前数值进行加1操作,关键的第三步调用compareAndSet方法来进行原子更新操作,该方法先检查当前数值是否等于current,等于意味着AtomicInteger的值没有被其他线程修改过,则将AtomicInteger的当前数值更新成next的值,如果不等compareAndSet方法会返回false,程序会进入for循环重新进行compareAndSet操作.

有序性

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。例如以下代码:

 double pi = 3.14;
 double r = 1.0;
 double area = pi * r * r;

单线程不会改变数据的依赖关系,但多线程时以上代码不一定是顺序执行的,可以用以下介绍的volatile来 禁止指令重排序,或者synchronized,保证一个变量同一时刻只允许一条线程对其进行lock操作。

Java并发相关术语解析

CPU术语

  • 内存屏障–memory barriers :处理器指令,对内存操作的顺序限制,有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)。只有一个CPU访问内存时,并不需要内存屏障。

  • 缓冲行—cache line : 缓存中可以分配的最小存储单位,处理器填写缓存线时会加载整个缓存线, 需要使用多个主内存读周期

  • 原子操作—atomic operations : 不可中断的一个或者一系列操作

  • 指令重排序:
    指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理

  • 缓存行填充—cache line fill : 当处理器识别到内存中是可缓存的,处理器读取整个缓存行到适当的缓存

  • 缓存命中–cache hit :如果进行高速缓存行填充的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作,而不是从内存读取

  • 写命中–write hit:当处理器将操作数写回到一个内存缓存的区域时,首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作称为写命中

  • 写缺失—write misses the cache : 一个有效的缓存行被写入到不存在的内存区域

线程间数据引入的混乱

举例如下:

public class Sequence{
 private int Value;
 public int getNext(){
   return Value++;  //非原子操作,先读取value,再+1,再给value赋值
	}
}

new Thread(A),new Thread(B)同时去访问这个对象,访问顺序和执行流程如下:

	A: value → 9    value+ 1      value = value + 1 = 10;
	B:         value → 9     value + 1			value = Value + 1 = 10;//事实上应该 = 11

解决方法,避免多个线程同时访问某一个变量,用同步锁:synchronized

public synchronized int getNext(){
	return Value++;
}

ThreadLocal

使用ThreadLocal可以防止对可变的单实例变量或者全局变量进行共享。例如:


 private static ThreadLocal<Connection> connectionHandler = new ThreadLocal<Connection>(){
 	public Connection initValue(){
 		return conection();
 	}
 }	
 
 public static Connection getConnection(){
 	return connectHolder.get();
 }
 
 ThreadLocal类似于Map<Thread,T>.这样线程间的数据互不干扰。

举例:

	public class Context(){
		int id;
		public void setId(int id){
			this.id = id;
		}
	}

然后一层层调用,xxx.doSomething() -> xxx.doSomething() -> xxx.doSomething();

那么当最后一层想要用到第一层的Context怎么办呢?再参数加上context,一直往下传递?这样栈就会变得很长。

或者直接把setId()改成static的?那么就可以直接用Context().setId()了,但是多线程时会有问题。

可以考虑用ThreadLocal,不同的线程持有不同的对象,互不干扰。

	public class Context(){
		public static final ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();
		
		public static void setId(int id){
			threadLocal.setId(id);
		}
	}

不可变对象

不可变对象一定是线程安全的。但是给域定义final不等于就是不可变了,因为final指向的对象可以保存对可变对象的引用。

例如:

  public class Test{
  	private final int[] marray;
  	
  	public Test(int[] array){
  		this.mrray = array; //用户可以在Test之外修改array的值。
  	}
  }   

String是不可变的,看看string的构造函数就知道了:

	public final class String{
		private final char value[]; //value[]是final修饰的
		public String(char value[]){
			this.value = Arrays.copyOf(value,value.length());
		}
	}

不可变对象的特性:

1.String类被final修饰,不可继承

2.string内部所有成员都设置为私有变量

3.不存在value的setter并将value和offset设置为final。

4.当传入可变数组value[]时,进行copy而不是直接将value[]复制给内部变量.

但是String对象也并非没有办法改变,如下,通过反射可以改变String的值:

	String s = "Hello world";
	
	Field valueFieldOfString = String.class.getDeclaredField("value");
	
	//改变value属性的访问权限
	valueFieldOfString.setAccessible(true);
	
	char[] value = valueFieldOfString.get(s);
	
	value[5] = "d";

synchronized 对象锁

  • 对于普通同步方法,锁是当前实例对象,别的线程只能再等待这个锁,不过其他没有锁的方法不受影响。

    如下两个方法是等价的。

     public synchronized void get(){}
	 
	 public void method(){
		 synchronized(this){}
	 }
	 
	 //没有锁不受影响,可以被别的线程访问。
	 public void test(){
		 
	 }
  • 对于静态同步方法,锁是当前类的Class对象。类锁和对象锁不会互相干预。单例模式的时候一般会用到类锁。
public class Test{
	   public void method(){
		 synchronized(Test.class){
			
		}
		//这两个方法等价,都是类锁。
	   public synchronized static void method(){}  
	
	 }
	}
  • 对于同步方法块,锁是Synchonized括号里配置的对象
     public void method(){
		 NOde head;
		 
		 synchronized(head){
			 //...
		 }}
	 
	 public class  Test{
		 private byte[] lock = new byte[0]; 
		 Object object = new Object();
		 
		 public void method(){
			 synchronized(object){
				.....
			 }
		 }
		 
		 public void test(){
		   synchronized(lock){
			....
		 }}}

线程安全的类库

  • Hashtable:
    Hashtable中采用的锁机制是一次锁住整个hash表,从而同一时刻只能由一个线程对其进行操作。所有线程都是竞争同一把锁,当有线程在put()时,另外的线程不能get().效率很低下,如下是Hashtable的put()和remove()方法,可见锁是当前对象:
public synchronized V put(K key, V value) {}
public synchronized V remove(Object key) {}
  • synchronizedMap : Collections的内部类,实现了Map,对所有的方法都进行了同步控制,但是不是百分百安全的,例如:
if(smap.contains(key)){
  smap.remove(key);
 }

当线程A执行contains时,返回true,这时去准备remove,然后这个时候线程B也来了,执行了contains,返回true,再去执行remove时,可能A已经将其remove掉了。

或者当一个线程在迭代时,

Iterator key = map.iterator(),while(key.hasNext()){//...} 

此时可能有另外一个线程正在修改元素的值。可以迭代的时候返回副本,或者锁住整个集合。但是效率会比较低,这个时候可以考虑用concurrentMap.

 public V get(Object key) {
    synchronized (mutex) {return m.get(key);}
   }

 public V put(K key, V value) {
   synchronized (mutex) {return m.put(key, value);}
   }
	  
  • concurrentMap:
    concurentMap主要实现原理是锁分段, HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把 锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据 的时候,其他段的数据也能被其他线程访问。

    而ConcurrentHashMap中则是一次锁住一个桶。ConcurrentHashMap默认将hash表分为16个桶,诸如get,put,remove等常用操作只锁当前需要用到的桶。 这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。

上面说到的16个线程指的是写线程,而读操作大部分时候都不需要用到锁。只有在size等操作时才需要锁住整个hash表。

在迭代方面,ConcurrentHashMap使用了一种不同的迭代方式。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。

		final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {}	//具体到桶时才加锁。对代码块同步。
  • CopyOnWriteArrayList\CopyOnWriteArraySet\synchronizedList\set

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

创建副本要加锁,不然多个线程同时创建多个副本就会导致内存的数据大量增长。

缺点:

内存占用问题:
因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存。

数据一致性问题:
CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果希望写入的的数据,马上能读到,最好不要使用CopyOnWrite容器。

  • BlockingQueue系列
    这是一种阻塞队列,用于保障线程安全,按 FIFO(先进先出)排序元素。有多种实现,举例LinkedBlockingDeque,队列的头部 是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。新元素插入到队列的尾部,并且队列检索操作会获得位于队列头部的元素。链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能比较低。

注意:

  1. 必须要使用take()方法在获取的时候达成阻塞结果,代码如下:

take():
从队列中取出元素E,如果队列为空,则阻塞该线程直到队列不为空拿出元素E位置;

 public E takeFirst() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        E x;
        while ( (x = unlinkFirst()) == null)
         notEmpty.await();
        return x;
        } finally {
            lock.unlock();
        }
    }

2.使用poll()方法将产生非阻塞效果,代码如下:

poll():
取出并删除队列中的首元素,如果队列为空,则返回null,不进行阻塞。队头为null直接返回null.否则就返回指定元素。

 private E unlinkFirst() {
        // assert lock.isHeldByCurrentThread();
        Node<E> f = first;
        if (f == null)   //获取第一个节点,如果为null,直接返回
            return null;
        Node<E> n = f.next;
        E item = f.item;
        f.item = null;
        f.next = f; // help GC
        first = n;  //first节点后移
        if (n == null)
            last = null;
        else
            n.prev = null;
        --count;
        notFull.signal();
        return item;
    }

Lock接口

  • synchronized :隐式的获取和释放锁。流程固化。

  • Lock的使用:可以自主控制锁的获取和释放。

   LOck lock = new ReentrantLock();
   lock.lock();
   try{ //不要在try中获取锁,因为如果获取锁发生了异常,异常抛出时,也会导致锁无故释放。
   }finally{
	   lock.unlock();
   }

Lock接口提供的synchronized不具有的主要特性

1.尝试非阻塞性的去获取锁,当前线程尝试去获取锁,如果可以则成功获取并持有锁

2.能被中断的获取锁:获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁也会被释放。

3.超时获取锁:在指定的截止时间之前获取锁,如果截止时间到了,仍然获取不到,则返回。

队列同步器 AbstractQueuedSynchronizer

  • 队列同步器: 构建锁或者其他同步组件的基础框架,使用了一个init成员变量表示同步状态,通过内置的FIFO队列来完成资源线程的排队工作。
    同步锁的设计是基于模板方法模式的,使用者需要继承同步器并重写指定的方法, 随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

    • getState() : 获取当前同步状态

    • setState() :设置当前同步状态

    • compareAndSetState(int expect,int update) :使用CAS设置当前状态,该方法能够保证状态设置的原子性。

同步器提供的模板方法基本分为3类:

独占式获取与释放同步状态、共享式获取与释放同步状态、查询同步队列中的等待线程情况。

实例:

      class Mutex implements Lock{
		  //静态内部类,自定义同步器
		  private static  class Sync extends AbstractQueuedSynchronizer{
			  //是否处于占用状态
			  protected boolean isHeldExlusively(){
				  return getState() == 1;
			  }
			  //当状态为0的时候获取锁
			  public boolean tryAcquire(int acquires){
				  if(compareAndSetState(0,1)){
					  setExclusiveOwnerThread(Thread.currentThread());
					  return true;
				  }
				  return false;
			  }
			  //释放锁,将状态设置为0
			  protected boolean tryRelease(int releases){
				  if(getState() == 0) throw new IllegalMonitorStateException();
				  setExclusiveOwnerThread(null);
				  setState(0);
				  return true;
			  }
			  //返回一个Condition,每个Condition都包含了一个condition队列
			  Condition new Condition(){
			    return new ConditionObject();}
			  }
		  }
		  
		  //将操作代理到Sync上即可
		  private  final Sync sync = new Sync();
		  public void lock(){sync.acquire(1));}
		  //...
		  
	  }	 

1.同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(NOde)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

2.独占式同步状态获取与释放

通过同步器的acquired(int args)获取同步状态、

重入锁

  • ReentrantLock : 支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。还支持获取锁时的公平和非公平性选择。

  • 公平性 : 先对锁获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。即等待时间最长的最优先获取到锁,即是说锁的获取时顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。

实现重入锁

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题。

  • 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
    ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,默认是非公平的。

非公平锁的举例:

final boolean nonfairTryAcquire(int acquires){
	final Thread current = Thread.currentThread();
	int c = getState();
	if(c == 0){
		if(compareAndSetState(0,acquires)){
			setExclusiveOwnerThread(current);
			return true;
		}
		 }else if(current = getExclusiveOwnerThread()){
			 int nextC = c + acquires;
			 if(nextC < 0) throw new Error("Maximum lock count exceeded");
			 setState(nextC);
			 return true;
		  }
		 return false;
	  }
   

公平与非公平锁的区别

公平:先来先得。从同步队列中的第一个节点获取到锁
非公平:当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获得同步状态的几率会比较大,使得其他线程只能在同步队列中等待。

公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

读写锁

锁(如Mutex和ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock。

可以同时有多个线程获取读锁,但是只有一个线程能够获取写锁,并且之后的其他线程也被阻塞,等待写锁释放。

如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。

举例:

public class Cache{
   	  static Map<String,Object> map = new HashMap<String,Object>();
   	  static ReentrantReadWriteLock rw1 = new ReetranReadWriteLock();
   	  static Lock r = rw1.readLock();
   	  static Lock w = rw1.writeLOck();
   	  
   	  //获取一个Key对应的value
   	  public static final Object get(String key){
   		  r.lock();
   		  try{
   			  return map.get(key);
   		  }finally{
   			  r.unlock();
   			  }
   		  
   	  }
   	  
   	  //设置key对应的value,并返回旧的value
   	  public static final Object put(String key,Object value){
   		  w.lock();
   		  try{
   			  return map.put(key,value);
   			  
   		  }finally{
   			  w.unlock();
   			  }		  
   	  }
   	  
   	  //清空所有的内容
   	  public static final void clear(){
   		  w.lock();
   		  try{
   			  map.clear();
   		  }finally{
   			  w.unlock();
   		  }
   	  }
     }
  

锁降级

锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

举例:

	 public void processData(){
		   readLock.lock();
		   if(!update){
			   //必须先释放读锁
			   readLock.unlock();
			   //锁降级从写锁获取到开始
			   writeLOck.lock();
			   try{
				   if(!update){
					   //准备数据的流程...
					   update = true;
				   }
				   readLock.lock();
			   }finally{
				   writeLOck.unlock();
			   }
			   //锁降级完成,写锁降级为读锁
			   
		   }
		   try{
			   //使用数据流程...
			   
		   }finally{
			   readLock.unlock();
	   }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值