JAVA中关于并发的一些理解

一,JAVA线程是如何实现的?

同步,涉及到多线程操作,那在JAVA中线程是如何实现的呢?

操作系统中讲到,线程的实现(线程模型)主要有三种方式:

①使用内核线程实现

②使用用户线程实现

③使用用户线程加轻量级线程实现

二,JAVA语言定义了哪几种线程状态?

JAVA语言定义了五种线程状态:①新建(New),当你 new 了一个Thread,但是并没有调用它的 start()方法时,就处于这种状态。

②运行(Run),这里包含了两种状态:一种是可运行状态,就是你调用了Thread的start()方法之后,但是该线程还未获得CPU(相当于操作系统中讲的就绪状态);另一种是运行状态,就是该线程被调度器分配了CPU,正在执行。

③等待(Waiting),处于这种状态的线程不会被分配CPU执行时间,它需要等待其他线程唤醒。等待又分成两种:无限等待和超时等待(限期等待)。

无限等待一般是执行等待的方法没有指定超时参数

比如Object类的wait()方法,会使线程进入无限等待状态,它还有一个带有 timeout(超时) 参数 的重载方法 Object.wait(long timeout),使线程超时等待。

调用Thread.sleep() 、 Object.wait()、Thread.join()方法都会使线程进入等待状态。

④阻塞(Blocked),个人感觉阻塞是与锁有关,而等待并不一定与锁有关。

比如,两个线程争夺对象锁(synchronized),未获得锁的那个线程将进入阻塞状态。而线程进入等待状态则有可能是因为③中提到的调用了Thread.sleep()方法,或者是线程读某个Socket端口上的数据,但是此时数据还未到达,则线程进入等待状态。

 ⑤结束(Terminated),线程的run方法运行完毕,进入结束状态。

三,同步(加锁)为什么会有代价?

《深入理解JVM》中讲到,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程序上决定了JAVA虚拟机的线程是怎样映射的。JAVA的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统帮忙完成,这就需要从用户态切换到核心态中,因此状态转换需要耗费很多的处理器时间

而在JAVA里面要实现同步,一种是使用对象锁,即synchronized关键字;另一种则是使用 java.util.ReentrantLock。获得锁的线程进入临界区执行、未获得锁的线程会阻塞,然后在某种条件下被唤醒。因此,同步(使用锁)是有代价的。

四,对象锁同步(synchronized同步) 与 ReentrantLock同步的区别

它们都是可重入锁,可用来互斥同步,但主要有三个方面的区别

①使用synchronized进行同步的线程在阻塞等待时,是不可中断的。而使用ReentrantLock进行同步的线程在阻塞等待时可中断。

如果临界区需要执行很长的时间,synchronized就只能一直阻塞等待了,而ReentrantLock 可以在阻塞等待一段时间之后,若还未获得锁,就可以被其他线程中断,从而去干其他事情。一个很好的示例可参考:ReentrantLock锁实现中断线程阻塞

通过调用 lock.lockInterruptibly()方法来实现线程在阻塞等待时 可被其他线程 中断。

②ReentrantLock可以实现公平锁

 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。而非公平锁则按照抢占的方式来获得锁,synchronized中的锁是不公平的,而ReentrantLock可以在构造函数中指定创建的锁是否为公平锁。

public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

③使用ReentrantLock 锁可以同时绑定多个条件变量(Condition对象)

所谓 条件对象 就是说:线程好不容易获得了锁 进入临界区,却发现需要在满足某一条件之后,它才能执行。因此,使用一个条件对象来管理那些已经获得了锁但是却不能做有用工作的线程。

比如说:生产者--消费者模型,消费者获得了队列的锁,去队列中取产品,但是结果发现队列为空,它没有产品可消费。换句话说:它需要在队列不为空的条件下,才能消费产品。(没有产品,肯定无法消费产品啊!)

比如,消费者的代码一般是下面这样:进入consume()方法需要获得锁,但是却发现队列为空,故只能调用wait()方法 进入等待状态。(不是阻塞状态)

复制代码

public synchronized void consume(){
        while(queue.isEmpty())
            wait();//没有产品可消费,只能放弃锁,并等待生产者线程生产了产品之后,唤醒它
        //consume product
        notifyAll();
        //....
    }

复制代码

那ReentrantLock呢?

复制代码

Condition emptyCondition = lock.newCondition();
    Condition fullCondition = lock.newCondition();
    .......
    public synchronized void consume(){
        try{
            lock.lock();//ReentrantLock lock
            while(queue.isEmpty())
                emptyCondition.await();
            //consume product
            emptyCondition.singalAll();
            //....
        }finally{
            lock.unlock();
        }
    }

复制代码

对于同一把ReentrantLock,它可以 new多个Condition,即同一把锁可以关联多个条件变量。

Condition.awit()方法的JDK源码解释非常值得一读。部分摘录如下:

Causes the current thread to wait until it is signalled or
     * {@linkplain Thread#interrupt interrupted}.

* <p>The lock associated with this {@code Condition} is atomically
     * released and the current thread becomes disabled for thread scheduling
     * purposes and lies dormant until <em>one</em> of four things happens

调用Condition.await()方法使线程放弃锁,并进入等待状态(不是阻塞状态),直到有下列四种情况发生 才从等待状态退出.....

 .....

五,互斥同步 与 非阻塞同步 是什么?

所谓互斥同步,就是多个线程争夺一把锁时,未获得锁的那些线程将会阻塞。因此,互斥同步最主要的是进行线程阻塞和唤醒带来的性能问题,因此 互斥同步称为阻塞同步。从处理问题的方式上看,它是一种悲观的并发策略:它认为如果不采取正确的同步措施,那执行就可能出问题。也就是说:它总是对共享数据先进行加锁,然后再去访问,尽管在访问过程中 也许 并没有 其他线程 访问该共享数据。

而正如前面(三)中 提到,加锁是有代价的,如果对共享数据加了锁,但是在访问共享数据过程中,并没有其他线程来访问该共享数据(即并没有出现竞争),那这次加锁就感觉有点浪费了。(就相当于:花了大量的人力、物力应对某次可能发生的地震,但是最终地震没有发生)

于是,为了进一步的”优化“,就出现了基于冲突检测的乐观并发策略

乐观并发策略就是:先进行操作,如果没有其他线程争用共享数据,那么操作就成功了。如果数据有争用,产生了冲突,那再采用补救措施。

因此,乐观并发策略的很多实现都不需要把线程挂起(因为它是 先执行了 操作再说),因而称为:非阻塞同步。

乐观并发策略需要硬件指令集的支持。因为,它在操作的过程中需要检测是否发生了冲突,需要“操作”和“冲突检测”这两个步骤具备原子性。原子性如何保证?如果用互斥同步保证 那就又变回 悲观并发策略 了,因此只能靠硬件来保证原子性。

比如java.util.concurrent.atomic.AtomicInteger.java 中的自增加1方法getAndIncrement(),就是通过硬件原子指令:CAS指令(Compare and swap)来实现

复制代码

public final int getAndIncrement() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
    }

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

复制代码

六,关于锁优化的一些知识

1)自旋锁 的 实现思想

在多核CPU下,一个线程获得锁进入临界区占用CPU执行时,我们有理由相信这个临界区代码很快就会执行完成,即: 共享数据的锁定状态只会持续很短的一段时间,而当另外一个线程刚好在这段时间去抢占锁,挂起这个抢占锁的线程有点 不值得。(毕竟占用锁的线程很快就会把锁释放了呀)

于是,就让请求抢占锁的那个线程“稍微等待一下”,但不放弃处理器的执行时间(一旦放弃,就意味着需要挂起和恢复线程了),看持有锁的线程是否很快就会释放锁。为了让线程等待一下,我们只需让线程执行一个忙循环(自旋),这就是:自旋锁。

2)自旋锁的“改进”---自适应自旋

 自旋等待避免了线程切换的开销,但是它是要占用处理器时间的。因此,如果锁被占用的时间很短,那自旋等待的效果就会非常好;如何锁被占用的时间很长,执行忙循环(自旋)的线程就白白消耗处理器资源,造成性能上的浪费。默认情况下,自旋的次数是10次,但可以通过JVM参数进行修改。

为了应对锁被占用很长时间 而导致的长时间无效的自旋,自旋的时间必须有一定的限度。

那如何确定一个合适的限定呢?这就是自适应自旋的目标了。

自适应自旋对自旋的次数没有固定,比如说:在同一个锁对象,自旋等待刚刚成功获得过锁,那么这次也很有可能成功,进而允许自旋等待持续相对更长的时间。

再比如说,对于某个锁,自旋很少成功获得过锁,那在以后获取这个锁时可省略自旋过程,以避免处理器资源浪费。

3)轻量级锁和偏向锁

 个人感觉轻量级锁和偏向锁 的功能 与 缓存 的思想有点像。直接同步互斥加锁的代价是很大的(重量级锁),那我们可以先来一个轻量级锁或偏向锁。

如果在加了 轻量级锁或偏向锁的过程中 没有发生其他线程来争抢锁(类似于缓存命中!)这意味着整个过程“几乎”不需要同步。

如果有其他线程争抢锁,那轻量级锁将不再有效(偏向锁的偏向模式失效),轻量级锁要膨胀为“重量级锁”,后面等待锁的线程要进入阻塞状态。这就是类似于缓存未命中!

关于轻量级锁和偏向锁的具体解释:可参考《深入理解JVM》

上面关于锁的优化是JVM的一些锁优化策略,在应用层进行锁优化方式有如下:

①尽量减少锁的持有时间。只对必要的需要同步的代码进行同步。

synchronized{this}
{
    methodA();// 把不需要同步的方法放到 sync 外面去
    mutex();
  //other method...// 把不需要同步的方法放到 sync 外面去
}

②减少锁的粒度

ConcurrentHashMap就很好的应用了这种思想。它将整个HashMap分成了若干个段(Segment),每个段都有自己的锁,每个段负责管理HashMap中的一部分HashEntry,段与段之间的HashEntry互不干扰,多线程可以并行地操作不同的Segment管理下的HashEntry。

这样锁的粒度就减少了。如果整个HashMap只有一把锁管理,锁的粒度就很大。操作HashMap不同的区域都需要互斥同步。而ConcurrentHashMap将HashMap分解成段,每个段有一把锁,锁的粒度就少了。但是与此同时,锁的数量增多了。当需要访问ConcurrentHashMap的全局属性时(比如ConcurrentHashMap的size()方法),需要 获得 所有的段的锁。

1         try {
2             for (;;) {
3                 if (retries++ == RETRIES_BEFORE_LOCK) {
4                     for (int j = 0; j < segments.length; ++j)
5                         ensureSegment(j).lock(); // force creation

以上是size()方法的部分代码,size()的具体实现肯定也有相应的优化。

③锁分离

锁分离与锁分段有点相似,锁分离就是对不同的操作使用不同的锁。比如,java.util.concurrent.LinkedBlockingQueue 是一个线程安全的阻塞队列,take()方法从队列中取元素,put()方法向队列中添加元素。

这个队列是用链式存储结构实现,它的结点类如下:

复制代码

1     /**
 2      * Linked list node class
 3      */
 4     static class Node<E> {
 5         E item;
 6 
 7         /**
 8          * One of:
 9          * - the real successor Node
10          * - this Node, meaning the successor is head.next
11          * - null, meaning there is no successor (this is the last node)
12          */
13         Node<E> next;
14 
15         Node(E x) { item = x; }
16     }

复制代码

它的 put 操作 和 take 操作分别作用于队列的尾部和头部,并没有相互冲突。如果 take 和 put 都共享同一把锁,那么从队列中取走元素的同时,就不能向队列中添加元素,尽管它们互不“干扰”。

因此,为了提高并发效率,就使用了两把锁:一把 put 锁,一把 take 锁。这就是锁分离机制。

复制代码

1     /** Lock held by take, poll, etc */
 2     private final ReentrantLock takeLock = new ReentrantLock();
 3 
 4     /** Wait queue for waiting takes */
 5     private final Condition notEmpty = takeLock.newCondition();
 6 
 7     /** Lock held by put, offer, etc */
 8     private final ReentrantLock putLock = new ReentrantLock();
 9 
10     /** Wait queue for waiting puts */
11     private final Condition notFull = putLock.newCondition();

复制代码

take锁对应着 notEmtpy条件变量,putLock对应着一个 notFull条件变量。

在大部分情况下,多线程可以同时并行地向阻塞队列中添加元素和取出元素。比如线程A向队列添加元素的时候,并不阻塞线程B从队列中取出元素。

通过使用 take 锁 和 put 锁,将LinkedBlockingQueue的读写分离。优化了并发效率。

④锁粗化

这个与①中的尽量减少 锁的时间 思想相反。但它们适用的情况是不同的。

比如,当程序需要在 for 循环内部加锁时,每执行一次 for 循环中的操作就需要加锁一次,加锁之后执行临界区代码后又释放锁,这样加锁、释放锁的频率非常大,效率反而低了。

复制代码

1     public void syncMethod(){
 2         for(int i = 0; i < COUTN; i++)
 3         {
 4             synchronized(this){
 5                 mutex();
 6             }
 7             //do something, do not need lock
 8             synchronized(this){
 9                 mutex();
10             }
11             //do another thing which needs no lock
12         }
13     }

复制代码

这样,直接用synchronized修饰方法反而要更好一点。

七,参考资料

《深入理解JVM》周志明

http://thrillerzw.iteye.com/blog/2055486

原文:http://www.cnblogs.com/hapjin/p/5765573.html

猜你喜欢

转载自my.oschina.net/u/3787897/blog/1630673