java并发学习

1 并发理论简介

1.1 java线程模型

java线程模型建立在两个基本概念之上:

1.共享的,默认可见的可变状态

2.抢占式线程调度

也就是说,首先,统一进程中的所有线程应该可以很容易地共享进程中的对象;其次,能够引用这些对象的所有线程都可以 修改 这些对象;第三,线程调度器应该可以在几乎任何时候在cpu上调入或者调出线程

但是这种随时的线程调度很有可能是在方法执行到一半的时候被中断了,这样可能会出现状态不一致的对象,为了避免这样的风险,必须还要满足最后一点:对象可以被锁住。

1.2 设计理念

  • 安全性
  • 活跃度
  • 性能
  • 重用性

安全性是指不管同时发生多少操作,都能保证对象保持一致。

活跃度是指,在一个活跃的系统中,所有做出尝试的活动,最终 要么取得进展,要么失败(不能卡死在某处)

性能可以理解为给定资源能做多少工作

可重用性,没有人希望每次都要从头开始写并发代码吧,这时候设计一个好的可重用工具集就比较重要了

1.3  这些设计理念相互冲突的原因

  • 安全性跟活跃度互相对立:安全性确保坏事不发生,活跃度要求见到进展(就算是坏的结果)
  • 可重用系统倾向于对外开放内核,可能会引发安全性
  • 如果通过大量的锁来保证安全性,那性能通常不会好

要想最终达到一种平衡状态,既能灵活适用于各种问题,又安全,活跃度和性能也保持在一定的水平,做到这些还是挺困难的。

1.4 系统开销之源

并发系统中的开销来自:锁与检测、环境切换、线程的个数、调度、内存局部性、算法设计。

其他都比较好理解,这里稍微带两句局部性,现代的CPU有缓存来加速内存读取,其可以更快地读取最近访问过的内存毗邻的内存。基于这一点,我们通过保证处理的数据排列在连续内存上,以提高内存局部性,从而提高性能。

1.5 一个事务处理的例子

如下图,用不同的线程池表示不同的程序处理阶段,每个线程池逐一接收工作项,处理完后交给下一个线程池处理如下图。

这种设计用java.util.concurrent包里的类很容易实现。这个包里有执行任务的线程池(Executor)、在不同线程池间传递工作的队列、并发数据结构(concurrentHashMap)以及其他很多底层工具 folk-join框架,同步器比如cyclicBarrier countDownLatch Exchanger semaphore SynchronousQueue等等

2.块结构并发(jdk1.5之前)

以前这种原始的,低级的通过synchronized,volatile等关键字来实现的多线程有何不妥之处?

2.1同步与锁

使用synchronized锁定的一些基本事实如下

  • 只能锁定对象,不能锁定基本类型
  • 被锁定的对象数组中的单个对象不会被锁定
  • synchronized方法可以视为等同于用synchronized(this)包住的代码块,但是他们二进制代码是不一样的
  • 静态同步方法锁定的是class对象,因为没有实例对象
  • 如果要锁定一个类对象,显示锁定和用getclass方法是不一样的,我的理解是getclass永远获取的是运行实例的类对象
  • 内部类的同步是独立于外部类的(因为编译成字节码之后,外部类和内部类是两个不同的class)
  • synchronized并不是方法签名的组成部分,所以不能出现在接口的方法声明中
  • java的线程锁时可重入的,也就是说持有锁的线程在遇到同一个锁的同步点(比如说一个同步方法调用同一个类里的另一个同步方法)时可以继续执行。

2.2线程的状态模型

如下图

 使用new操作符新创建一个线程的时候,该线程还没运行,处于上图中的新创建状态。

调用strat方法之后,就进入可运行状态,可运行状态的线程可以处于运行状态也可能处于未运行的状态,这个具体取决于操作系统是不是给了他时间片。这个图之所以把其他其他地方见到的一些线程模型里的「准备就绪」和「运行」两个状态画在一起作为「可运行」状态,是因为这两个状态在接收到通知或者请求锁时,状态变化是一样的。

被阻塞和等待状态

他们共同点在于线程处于这两种状态都是不活动的,不运行任何代码,消耗最少的资源知道线程调度器重新激活他们。

  • 当线程试图获取一个内部对象锁(而不是java.util.concurrent库里的Lock),而该锁被其他线程持有,这时候线程进入被阻塞状态(BLOCKED)。当其他所有持有该锁的线程都释放该锁,并且线程调度器允许本线程持有该锁的时候,状态才恢复为非阻塞。
  • 当线程等待另一个线程通知调度器一个条件的时候,他自己进入等待(waiting)状态,比如调用Object.wait,Thread.join,或者等待java.util.concurrent库里的Lock或者Condition的时候
  • 超时等待(timed waiting)和等待的区别就在于多了一个超时参数,当超时时间到了也会退出该状态。带超时参数的方法有Thread.sleep,Object.wait,Thread.join,Lock.tryLock,Condition.wait的计时版本。

当一个线程从被阻塞或者等待状态恢复的时候,也不一定就立即能运行,调度器会去判断优先级,如果它优先级高于某个正在运行的线程这个时候才会剥夺一个低优先级的运行线程让他来运行。

被终止可 能因为 1.run方法 自然退出 2.因为一个未捕获的异常终止了run方法

为什么是synchronized

为什么java用来标识临界区的关键字 是synchronized,不是critical locked之类的词?

回答这个问题首先要知道的是,由于现在我们正处于多核时代,所以在考虑多线程程序时,必须把多个线程在同一时刻都在执行并且很可能在操作共享的数据考虑进去。为了提高效率,同时运行的每个线程可能都有他正在处理的数据的一个复本。

所以现在就有了主内存和线程本地内存的概念,临界区代码块执行完后,对被锁定对象所做的任何修改,都会被刷新到主内存中去。于是我们可以看到被synchronized的是在不同线程中表示被锁定对象的内存块。另外,当进入一个同步的代码块,得到线程锁之后,对被锁定对象的任何修改都是从主内存中读取出来的,所以在锁定区域代码执行之前,持有锁的线程就和锁定对象主内存中synchronized了。

关键字volatile

一个volatile域遵循以下的的规则:

  • 线程所见的值在使用前总会从主内存读出来
  • 线程所写的值总会在指令完成前刷新回主内存

可以把volatile域看成一个小小的同步块,但是 只有写入时不依赖于当前状态的变量才应该被声明为volatile,举个例子,银行账户的余额,有多个线程在同并发地对其进行增减的时候,声明为volatile并不能保证最终结果是正确的。

不可变性

不可变对象没有状态,或者只有final域(因此只能在构造方法中赋值)。因为不可变对象的状态不可修改,因此不可能出现不一致的情况。

这边插入一个设计模式相关的知识:构建器模式。假如我们要创建一个不可变对象,假设这个对象需要有很多个域,那如果过通过构造方法去构造对象,就必须把传递很多个参数给构造方法,这样看起来又蠢又不便。有一个改进的方法是用工厂方法来生成需要的对象,但是还是避免不了传递大量的参数给工厂方法。

构建器模式可以解决这个问题:它由一个实现了构建器泛型接口的内部静态类,一个构建不可变类实例的私有构造方法组成。

//构建器接口
public interface ObjeectBuilder<T> {
    T build();
}
//需要通过构建器构造对象的类,这里的例子是一个不可变类
public class update() {
    //以下为final域,只能在构造方法中初始化
    private final Author author;
    private final String updateText;
    //私有的构造方法
    private Update(Builder builder) {
        author = builder.author;
        updateText = builder.updateText;
    }
    //内部静态构造器类
    //静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地
    //保存着一个引用,该引用是指向创建它的外围内,但是静态内部类却没有。没有这个引用就意味着:
    //  1、 它的创建是不需要依赖于外围类的。
    //  2、 它不能使用任何外围类的非static成员变量和方法。
    public static class Builder implements Object<Update> {
        private Author author;
        private String updateText;
        
        public Builder author(Author _author) {
            author = _author;
            return this;
        }
        
        public Builder updateText(String _updateText) {
            updateText = _updateText;
            return this;
        }
        
        @override
        public Update build() {
            return new Update(this);
        }    
    }

}
//最后调用的时候如下,可以链式调用,看起来很简洁,而且需要生成的对象是链式调用的最后一步
//调用build方法的时候才生成,避免了有些情况下可能出现的同步问题
Update.Builder ub = new Update.Builer();
Update u = ub.updateText("Hello").author(someAuthor).build();

现代并发应用程序的构件

jdk1.5开始引入的java.util.cocurrent包包含了大量编写多线程代码的新工具,下面简单介绍一下。

原子类 java.util.concurrent.atomic

java.util.concurrent.atomic包里有几个以Atomic打头的类,比如AtomicInteger,AtomicLong等等,他们语义上基本和volatile一样,但是比volatile好,用这些类可以保证多线程都getandset同一个值也保持正确。

线程锁 java.util.concurrent.locks

块结构同步其实使用的是对象的内部锁,这种锁有一些缺点:

  • 这种锁只有一种类型
  • 只能在同步方法或者同步代码块开始结束的地方取得/释放锁
  • 线程要么获得锁,要么阻塞,没有别的可能

如果想对上面的锁进行改进,可以有如下几点:

  • 支持不同类型的锁,比如读取锁和写入锁
  • 可以在一个方法上锁,在另外一个方法解锁
  • 如果线程得不到锁,允许线程继续执行或者先做别的事(通过tryLock方法)
  • 获取锁应该有一个超时机制,超时获取不到则放弃

java.util.concurrent.locks中的Lock接口有两个实现类,一个是ReentrantLock,另一个是ReentrantReadWriteLock,读线程很多,写线程很少的时候用ReentrantReadWriteLock性能会比较好。

CountDownLatch

简而言之,使用方法是先新建一个CountDownLatch,参数是一个整数表示需要countdown多少下,

CountDownLatch cdl = new CountDownLatch(num);

然后在子线程中  cdl .countDown();

然后在主线程中cdl.await();

这样主线程就能保证在num个子线程执行完后才能继续往下执行。

 

concurrentHashMap

concurrentHashMap是标准的HashMap的并发版本。下面就比较一下他们。

在java8中,HashMap和ConcurrentHashMap的实现都做了修改。

首先是java8之前(以java7为例),HashMap结构大体上如下图,就是一个数组,数组的每个元素是一个链表

然后是java7中的ConcurrentHashMap,基本思路和HashMap差不多,也是一个数组,数组里面再放存放元素的结构,和HashMap不一样的地方在于1,这个数组不能扩容,一旦初始化的时候大小定了就不能再改变 ;2,这个数组的元素是segment,继承了ReentrantLock,其实这个segment和标准HashMap有点像,他是一个数组,元素是链表,只不过为了实现并发,需要加锁操作。结构如下

到了java8中,HashMap代码进行了重写,因为java7中的实现,对于每个数组元素的链表查询时,时间复杂度是O(n),这里是有优化的余地的,优化方法:每个链表的长度超过8以后,将链表转为红黑树,红黑树的查询时间复杂度是O(lgN),比O(N)的时间复杂度好出很多。

 java8中的ConcurrentHashMap放弃了分段锁的做法,改用 CAS + synchronized 控制并发操作,在某些方面提升了性能。并且追随 java8的 HashMap 底层实现,使用数组+链表+红黑树进行数据存储。

 

CopyOnWriteArrayList

CopyOnWriteArrayList通过增加写时复制(copy-on-write)语义来实现线程安全。修改CopyOnWriteArrayList的任何操作都会创建一个back array的新复本。所以在用迭代器迭代CopyOnWriteArrayList的时候,一旦iterater生成了就不用担心遇到意外的修改,所以不会在多线程操作list的时候产生ConcurrentModificationException。

CopyOnWriteArrayList的使用还是挺复杂的,因为他会多出一个backing array,如果数据量大的话,恐怕会触发young GC甚至fullGC,那就会拖慢性能了。一般将其使用在写的很少,读取较多的场合,而且对数据实时一致性要求不高的场合,因为CopyOnWriteArrayList只能保证数据的最终一致性。书上的一个例子微博的时间线就不错,时间线如果某个时候少了那么一条正在插入的更新,影响并不大。

Queue

最基本的blockingQueue有两个默认的实现:LinkedBlockingQueue和ArrayBlockingQueue,一般来说已知队列大小且能确定合适的边界的时候用ArrayBlockingQueue性能稍好一些。

BlockingQueue的两个特性:队列满时put()被阻塞,队列空时take()被阻塞

☆ Queue接口都是泛型的,但是一般不要直接这样用:BlockingQueue<MyWork>,而是再封装一层:BlockingQueue<QueueObject<MyWork>>,再封装一层的好处有很多,如果测试或者有其他需要时,可以在QueueObject类中添加所需要的metadata,比如测试信息,性能数据等等,而不需要修改实际的任务类MyWork。

BlockingQueue有个问题,如果用两个线程一个处理生产,一个处理消费,生产者消费者的生产、消费速度差的有点多的话,很快队列要么一直是空的,一直是满的。Java7开始有了TransferQueue来解决这个问题。

TransferQueue

之前说过,BlockingQueue当生产者向队列添加元素但队列已满时,生产者会被阻塞;当消费者从队列移除元素但队列为空时,消费者会被阻塞。而TransferQueue继承了BlockingQueue,所以一起BlockingQueue有的操作他都有,另外它还支持这样的操作:生产者会一直阻塞直到所添加到队列的元素被某一个消费者所消费(不仅仅是添加到队列里就完事)。新添加的transfer方法用来实现这种约束。

当然了,还有tryTransfer方法,包括带timeout和不带timeout的。

不带timeout的:若当前存在一个正在等待获取的消费者线程,则该方法会即刻转移e,并返回true;若不存在则返回false,但是并不会将e插入到队列中。这个方法不会阻塞当前线程,要么快速返回true,要么快速返回false。

带timeout的:若当前存在一个正在等待获取的消费者线程,会立即传输给它; 否则将元素e插入到队列尾部,并且等待被消费者线程获取消费掉。若在指定的时间内元素e无法被消费者线程获取,则返回false,同时该元素从队列中移除。

表示可执行代码片段的另一个接口

之前创建的线程都是用的java.lang.Runnable作为可执行的代码片段,使用Runnable是不能直接返回返回值的,而且Runnable的执行方法run()也不能抛出异常。 现在有一个新的可执行代码片段接口:Callable,它位于java.util.concurrent包下,也只有一个方法:call(),  这是一个泛型接口,call()函数返回的类型就是传递进来的V类型。

Callable的使用一般需要和Future,FutureTask配合使用,Future是一个接口,FutureTask是一个类,他们之间的关系,手上没有画类图的软件,简单描述一下吧,接口RunnableFuture extends Future接口和Runnable接口,而FutureTask   implements   RunnableFuture 接口,所以FutureTask既可以作为Runnable作为可执行代码段,也可以作为Future来接收Callable执行的结果。

分支/合并框架 

引入了一个新的executor服务,称为ForkJoinPool,他可以处理比线程更小的并发单元:ForkJoinTask,而ForkJoinTask可以再被分为更小的ForkJoinTask,从而实现分而治之。

一个例子是多线程版本的mergesort,可以递归地实现,使用的是ForkJoinTask的子类RecursiveAction类

ForkJoinTask支持 工作窃取 机制,分之合并框架可以自动地将负荷满的线程上的工作重新安排到负荷较轻的线程上去。

什么情况下适用分支合并框架呢?有如下的这么一个checklist

  • 问题的子任务是不是无需和其他子任务有协作或者同步也能工作?
  • 子任务是不是不会修改数据,只是经过计算得出一些结果?
  • 子任务是不是自然而然可以被分为更细小的子任务?

如果上面几个问题回答都是“是”或者“大体上如此”的话,应该是属于适用分支合并框架的情形,反之则可能不太适合,需要考虑其他的同步方式。

Java内存模型

JLS里面对于java内存模型的定义非常学术化,但是概括来说有两个基本的概念:Happeens-before和Synchronizes-with.

Happeens-before表示一段代码在开始执行之前另一段代码已经执行完成。

Synchronizes-with表示动作执行之前必须把它的对象视图与主内存进行同步。

针对上面的两个概念,JMM的主要规则如下:

  • 在监测对象上的解锁操作与后续的锁操作之间存在Synchronizes-with关系
  • 对volatile变量的写入与后续读取之前存在Synchronizes-with关系
  • 如果动作A Synchronizes-with 动作B,那么动作A Happeens-before动作B
  • 如果线程中动作A出现在动作B之前,那么A 必定Happeens-before B

还有一些关于敏感行为的规则:

  • 构造方法必须在对象的终结器开始运行之前完成
  • 开始一个线程的动作与这个新线程的第一个动作是Synchronizes-with关系
  • Thread.join()方法与这个线程的最后一个动作也是Synchronizes-with关系
  • Happeens-before 关系存在传递性,亦即A Happeens-before B,B Happeens-before C,则 A Happeens-before C

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值