java基础中一些值得聊的话题(并发篇)

   java的并发体系也是非常庞大,而且注意点非常多。这里可能不会面面俱到(否则就是写书了)。

  之前写过一篇关于java并发方式的文章。

  这里以一个从重到轻的方式来详细聊聊java的并发。

  Synchronized和ReentrantLock

  最常见的同步方式是synchronized,之所以叫synchronized是因为synchronized会将锁作用于整个对象或者类,而不是具体某个方法,有几种用法,加在方法上,同步代码块以及同步整个class,前两者作用于对象,最后一种方式作用于类。其次是ReentrantLock,理解起来更加容易,就是一个锁,持锁的线程能够进入执行,无锁的线程则阻塞等待.

  使用锁容易犯的错误是没有搞清楚需要同步内容的范围。如下例,两个变量都已经作为原子变量来操作,然而,我们真正需要同步的语义是两个状态同时变化,因此实际上没有起到相应的作用。

class SynTest{
	
	private AtomicReference stateA;
	
	private AtomicInteger stateB;
	
	public void syn(boolean b){
		if(b){
			stateA.set("aaa");
			stateB.set(10);
			return ;
		}
		stateA.set("bbb");
		stateB.set(5);
	}
}

  锁优化

  减少锁持有时间。基本思想就是最小化锁定持有的代码段,不该锁定的代码不要加锁增加时间成本。

  减少锁粒度。实际上是在减小数据结构的锁定部分。比如ConcurrentHashMap中对segment做的分段锁

  锁分离。基本思想是对大锁做锁的拆分。比如ReadWriteLock就是从读和写的维度来分离了读写锁;LinkedBlockingQueue中对队头和队尾的put和take的锁分离。

  锁粗化。过度减少锁的持有时间有可能会带来频繁的加减锁,典型的例子是循环内加锁。

  对于synchronized中锁优化的方式:

  最初采用偏向锁,目的是解决同一个线程多次获取锁的开销,当一个线程首次获得锁之后,该锁即进入偏向模式,使得今后同一个线程再次获取该锁时无需额外开销;如果发现线程经常在抢占切换,则偏向锁失效,变为轻量级锁,轻量锁适合线程交替执行,但不是同时抢占的场合,轻量锁采用CAS方式将Mark Word替换为指向锁记录的指针,如果失败,则自旋获取锁;如果自旋获取锁仍然失败,则轻量级锁膨胀为重量级锁。

  使用线程安全的数据结构

  线程安全的数据结构有很多,如Vector,Hashtable,ConcurrentHashMap, ConcurrentLinkedList等等,关于数据结构,还得另外写文再讲讲。

  一个常见的错误是,误以为只要使用了这些数据结构,就一定不会出现线程安全的问题。比如下面的例子,就有可能会出现一个ArrayIndexOutOfBoundsException。

public class VectorTest{
	
	private static Vector v = new Vector();
	
	public static void main(String [] args){
		while(true){
		for(int i = 0; i < 10; i++){
			v.add(i);
		}
		
		Thread t = new Thread(new Runnable(){
			public void run() {
//				synchronized(v){
				for(int i = 0; i < v.size(); i++)
					v.remove(i);
//				}
			}
		});
		
		Thread p = new Thread(new Runnable(){
			public void run() {
//				synchronized(v){
				for(int i = 0; i < v.size(); i++)
					System.out.print(v.get(i) + "\t");
//				}
			}
		});
		t.start();
		p.start();
		
//		while(Thread.activeCount() > 20);
		}
	}
}

  原因是什么呢?Vector内部使用数组保存对象,它对这个数据结构本身的操作如add, remove, get等做了同步,因此多个线程同时操作vector时,能保证vector内部操作数组的过程是安全的。但是如果仔细观察,会发现实际上这里的语义比较微妙,这里的v.size跟remove和get并没有原子性,举个例子,线程p首先调用v.size,得到10,此时线程t抢占处理器并调用了remove删除了所有元素,此时线程p抢占回来准备get(0),于是抛出错误ArrayIndexOutOfBoundsException。v.size和v.get语义上要求原子但实际上没有,这就是问题所在。因此,应该使用一个更大范围的封闭,在v上对整个for循环同步(见注释)。

   atomic原子家族

   Atomic家族主要使用Unsafe类,见这篇文章。

   无锁对象引用主要使用AtomicReference,但是会带来ABA问题,使用AtomicStampedReference可以解决该问题。AtomicStampedReference在原引用比较的基础上增加了stamp的比较,因而可以解决ABA这种过程变化而结果不变的问题。

    java的内存模型与volatile
   volatile是java中比较难理解的语义之一。要理解volatile,先理解java的内存模型。这里有一些深入讲解内存模型与volatile的文章,推荐 http://www.ibm.com/developerworks/cn/java/j-jtp06197.html, http://ifeve.com/java-memory-model-4/

java基础中一些值得聊的话题(并发篇)_第1张图片

   对于不同的线程有各自的工作内存,其操作都是先把数据装载进自己的工作内存中,然后同步回主存。这个过程并非原子性的,比如从主存中读内容需要两步read,load,向主存写内容也需要两步store,write。因此还有锁的语义lock, unlock。

   java内存模型最主要揭示的一点就是操作的原子性,可见性及有序性。原子性指的就是一件事被当做整体做完。可见性指一个线程的写操作能够立马被另一个线程看到。

   而volatile最主要保证的就是内存的可见性和禁止指令重排它并没有保证原子性,这点要注意,很多时候会以为用了volatile就线程安全了,其实不然,看下面的例子(来源网上)。

public class JoinThread extends Thread  
{  
    public static volatile int n = 0;  
    public void run()  
    {  
        for (int i = 0; i < 10; i++)  
            try 
        {  
                n++;  
                sleep(3); // 为了使运行结果更随机,延迟3毫秒  
            }  
            catch (Exception e)  
            {  
            }  
    }  
 
    public static void main(String[] args) throws Exception  
    {  
 
        Thread threads[] = new Thread[100];  
        for (int i = 0; i < threads.length; i++)  
            // 建立100个线程  
            threads[i] = new JoinThread();  
        for (int i = 0; i < threads.length; i++)  
            // 运行刚才建立的100个线程  
            threads[i].start();  
        for (int i = 0; i < threads.length; i++)  
            // 100个线程都执行完后继续  
            threads[i].join();  
        System.out.println("n=" + JoinThread.n);  
    }  
}  
   这个程序实际上总是打印出来小于1000的值,这说明对于++运算和n = n + 1这样的运算而言,volatile不能保证其原子性。要保证其原子性,只需要将volatile换为AtomicInteger,再将n++换为n.addAndSet(1)。从字节码来看,++指令实际上被翻译成了这么几个指令,而这几个指令的执行并没有上锁,因此++操作对于上层而言是一个操作,但是底层是多个操作,不具有原子性(《深入理解JVM》一书中更是提到,即便在字节码层面的单操作,其底层汇编有可能是多个操作)。

     5  getstatic org.metro.core.JoinThread.n : int [10]
     8  iconst_1
     9  iadd
    10  putstatic org.metro.core.JoinThread.n : int [10]
   对于 指令重排,也是防不胜防,比如如下语句

boolean a=false;
在一个线程中
doSomeLoading();
a=true;

在另一个线程中
if(a){
    doSomeWork();
}
   第一个线程先要做一些loading的工作,完成之后将标志设为true,这样第二个线程才能真正做事。但实际上a=true有可能被重排到doSomeLoading之前,这样另一个线程有可能会在没有loading的情况先做doSomeWork导致出错。
   个人愚见,除非你很清楚在做什么,否则尽量使用Atomic变量代替volatile,因为其性能差异并不大。有意思的是,AtomicReference又在内部实现中用volatile来修饰被引用的对象,当然是希望用到volatile的可见性。

   关于并发包的内容

   一是ThreadPool;二是Callable与Future(关于future的其中一种用法,参考java超时控制);三是一些同步工具类,如CountDownLatch,Semaphore, Barrier

   

  AQS http://blog.csdn.net/vernonzheng/article/details/8275624

  深入锁机制 http://blog.csdn.net/chen77716/article/details/6641477

  JUC并发类详解 http://my.oschina.net/foxeye/blog/625886

  JAVA中的unsafe类 http://xiaobaoqiu.github.io/blog/2014/11/08/jie-mi-sun-dot-misc-dot-unsafe/

  synchronized、锁、多线程同步的原理是咋样的 https://www.jianshu.com/p/5dbb07c8d5d5

  深入理解Java并发之synchronized实现原理 http://blog.csdn.net/javazejian/article/details/72828483

你可能感兴趣的:(java)