0-1世界线程池程序员

关于java并发,线程池和array list的学习笔记

2017-04-14  本文已影响784人  锅与盆

最近看了些关于并发的东西,把总结的学习笔记附上。主要是和java并发相关的,所以把arraylist部分放最后了。

补充

1.

java里线程安全的问题除了普通的共享变量的多线程读写冲突,还有一些很容易被我们忽视的线程不安全的类(因为从它们的API里的确很难想到和线程安全有什么关系),比如我们在处理时间上经常用的SimpleDateFormat就是线程不安全的类。如果共享该实例,一旦在生产环境中一定负载情况下时,他会出现各种不同的情况,比如转化的时间不正确,比如报错,比如线程被挂死等等(这个算是 JDK的设计缺陷,原因是format方法里修改了成员变量,详见深入理解Java:SimpleDateFormat安全的时间格式化,所以jdk8里有新的类可以取代它)。上次新浪微博的面试中,面试官就问到了这个。给每个线程单独创建实例的问题是加重了创建对象的负担,创建这么一个实例代价比较大。加锁的话会造成性能差。所以处理方法一般推荐如下处理:

 private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {              
  @Override        
  protected DateFormat initialValue() {            
    return new SimpleDateFormat("yyyy-MM-dd");        
  }    
}; 

除了上述的ThreadLocal方法,也可以使用Apache Commons项目的DateUtils和DateFormatUtils工具类(在org.apache.commons.lang.time包下)来代替SimpleDateFormat进行时间格式转换。 如果是 JDK8 的应用,可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar, DateTimeFormatter代替Simpledateformatter,官方给出的解释:simple beautiful strong immutable thread-safe。(来自《阿里java开发手册》)
关于jdk8里关于时间的API,详见这篇文章Java 8:健壮、易用的时间/日期API终于来临
在jdk8之前,可能开发者会弃用jdk,改用使用其他类库中更易用的时间格式化类,比如Joda-Time或者Apache commons 里的FastDateFormat。但是jdk8后已经完全不需要,自带的api已经完全可以满足使用。

Java 8中的java.time是一个新的、复杂的时间/日期API。它把Joda-Time中的设计思想和实现推向了更高的层次,让开发人员把java.util.Date和Calendar抛在了身后。是时候重新享受时间/日期编程的乐趣了。

2.

另外也要注意,避免 Random 实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一 seed 导致的性能下降。
说明:Random 实例包括 java.util.Random 的实例或者 Math.random()实例。因为Math类中的random方法就是直接调用Random类中的nextDouble方法实现的。实际上是一样的。
正例:在 JDK7 之后,可以直接使用 API ThreadLocalRandom,在 JDK7 之前,和上面一样,可以自己用线程本地变量实现。关于ThreadLocalRandom可以看这篇文章了解创建并发随机数

3.thread local变量

ThreadLocald的jdk源码(jdk8)
关于这个ThreadLocal类也很有意思,我之前学的时候看有博客里讲ThreadLocal为每个线程创建独立的变量副本,然后就按这么记了,后来看源码的时候才发现理解一直是错的。网上有很多文章说TreadLocal的实现方法时都说在类里有一个Map,map元素的键为线程对象,而值对应线程的变量副本,其实源码并不是这么实现的,源码里是map.getEntry(this),就是key是TreadLocal对象。其实是每个 Thread 维护一个ThreadLocalMap,map 的 key 是 ThreadLocal 实例,value是对应的set进去的独立变量的引用。我们在调用get()方法的时候,先获取当前线程,然后获取到当前线程的ThreadLocalMap对象,如果非空,那么取出ThreadLocal的value,否则进行初始化,初始化就是将initialValue的值set到ThreadLocal中。
而且key是弱引用,这样当程序对这个thread local变量没有引用时,就可以对它进行GC,而不用等到线程消失以后再对GC,感觉这么设计的确很好。

3并发

3.1 Volatile

用这个关键字修饰的变量可以保证“可见性”,但是是不能保证原子性。意思就是当一个线程修改一个共享变量时,其他线程立刻就能读到修改过的值。比起synchronized的开销更小,因为它用的不是锁的原理,但是不能保证原子性,对于一写多读这种问题,是可以解决变量同步问题, 但是如果多写,同样无法解决线程安全问题。像count++操作即使变量被volatile修饰可能还是会出现问题,所以多线程的计数器一般是使用atomicinteger或者atomiclong类(它的原理是用CAS操作实现的乐观锁算法,具体后面有介绍)实现: AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 不过JDK8里引入一个LongAdder类,比 AtomicLong 性能还要好(它可以减少乐观锁的重试次数,atomic里面CAS操作是一个循环,高并发的时候可能一直修改失败就一直循环尝试,像自旋锁那样,longadder采用一个有点像concurrenthashmap分段处理那种思路的方法,高并发时候将单一value的更新压力分担到多个临时单元中,调用intValue()时候再累加进去,具体也没看过源码不太了解),Java 8 LongAdders:管理并发计数器的正确方式这篇文章里测试longadder的性能比atomic快一倍,好像也没有额外问题,所以应该用longadder取代atomicinteger作并发计数器了。

有一些资料说Volatile是轻量级的synchronized,我感觉不是很恰当,因为他们实现机制和思路都不太一样,synchronized是实现了一个锁机制(monitor监视器锁,锁放在对象头,jdk6大幅优化,引入“偏向锁”和“轻量级锁”,有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,随着竞争情况逐渐升级),
而volatile是通过让线程对变量进行修改之后,要立刻回写到主存。然后线程对变量读取的时候,要直接从主内存中读,而不是缓存,通过这种方式实现可见性。还有volatile会禁止指令重排优化。再具体的实现我看了一些资料一直也没弄的特别明白,因为很多资料说的也不一样,我理解大概就是,这个缓存可以看作有两层,一个是java内存模型里的,一个是硬件上的CPU缓存。Java为了保证平台性,有它自己独立于操作系统的内存模型,分主内存和工作内存,工作内存放在栈上,是线程独立的,除了包含了线程内部定义的局部变量,也包含了一些共享变量的副本,起一个缓存的作用。在这一层写Volatile修饰的变量要,先改变线程工作内存中volatile变量副本的值,将改变后的副本的值从工作内存刷新到主内存,这里应该是字节码里添加了控制jvm的指令,不涉及到操作系统层面,然后CPU硬件级别也是有缓存的,所以还有对应的汇编指令,(Lock前缀指令),在硬件层面上保证缓存的一致性,比如立刻把修改过的cpu缓存写回主存,并且通知其他cpu的缓存无效该地址的缓存行这样。那个指令重排也是涉及JVM层面和硬件层面两个层面。

大致应该是这样,因为这个比较底层,我还没完全弄明白。不过volatile这个关键字感觉在实际代码中用的也不多,主要是源码中见到比较多。我之前看到有篇文章说禁止重排序是volatile比较重要的一个应用场景,感觉目前还没遇到过。

3.2 Reentrantlock

JDK5以后引入的一个类,Reentrantlock是lock接口的一个具体实现类。这种显示的互斥机制必须显示的创建加锁释放,所以代码简洁性上差一点,但是更灵活,比如锁的控制能更精细一些,一个对象里用好几把锁控制,另外它必须在 finally 里解锁,所以不存在代码抛出异常,锁就有可能得不到释放的问题。而且它不仅是替代隐式的synchronized,它能够提供很多功能,比如可以构造公平锁(严格按照FIFO顺序获取锁),用trylock方法可以尝试获取或者尝试获取一段时间锁,如果获取不到也不会阻塞,可以继续做别的事。有一些过去的资料说synchronizd比reentrantlcok效率低,这是因为reentrantlcok基于AbstractQueuedSynchronizer这个类实现的,AQS, AQS又基于CAS操作,所以速度更快,过去synchronized是单纯的通过对象内部的监视器锁(monitor)实现了一个重量级锁,比较慢,但1.6以后synronized做了优化,引入偏向锁和轻量级锁和一些其他优化,减少了重量级锁的使用,所以效率其实上两种方式差不多,用显示锁目的主要还是还是它的灵活性和可定制性。

PS:重量级锁为什么慢?
这个主要原因应该和线程之间频繁切换,状态转换有关,因JVM的线程会映射到操作系统的线程,线程状态转换需要从用户态转换到核心态,所以需要相对比较长时间。

3.3 CAS实现的乐观锁

#######1.悲观锁乐观锁

悲观锁
就是很悲观,觉得一定会有并发冲突,所以每次操作都要保证只有自己拿到互斥锁,有独占性和排他性。但其实大多数情况并发冲突并没有那么严重,反而频繁的加锁释放锁造成性能低下。

乐观锁
比较乐观,觉得冲突没那么严重,核心思路就是每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。但如果冲突严重,线程总是拿不到锁,一直自旋也会造成CPU资源浪费。所以,当数据争用不严重时,乐观锁效果更好。比较常见乐观锁实现就是Atomic包里的类,用CAS操作实现。

#######2. CAS
(Compare and swap)算法思想就是比较和替换,如果内存地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。意思就是如果内存值和期望值不同,说明发生了并发冲突,已经有人修改过这个值,所以放弃修改。

当前的处理器基本都支持CAS,可能不同厂家算法不一样。 用Java5以后通过atomic包里的原子类可以调用CAS操作,JVM会使用运行机器的底层的CAS指令实现这个操作。

CAS虽然很高效的解决原子操作,但是CAS仍然存在问题。ABA问题,循环时间长开销大
ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。在jdk1.5里,已经用AtomicStampedReference类解决了这个问题。

循环时间长,atomic包里那些乐观锁实现里面CAS操作是一个循环,高并发的时候可能一直修改失败就一直循环尝试,像自旋锁那样。这个java8里的adder类(intvalue(),add(),)有解决。longadder采用一个有点像concurrenthashmap分段处理那种思路的方法,高并发时候将单一value的更新压力分担到多个临时单元中,调用intValue()时候再累加进去,具体也没看过源码不太了解

atomicinteger,atomicinteger类(用CAS操作实现的乐观锁算法)实现: AtomicInteger count = new AtomicInteger(); count.addAndGet(1);

4 线程池技术

思考:java5引入executor,java7引入Fork/Join,CAS实现的Atomic。。java8关于并发有什么新特性?concurrenthashmap的改进?还有什么?

4.1 线程池介绍

裸线程
过去大家创建线程一般是通过两种方式,创建 Thread 子类的一个实例并重写 run 方法,或者实现runnable接口,把runnable类做参数传递给thread的构造方法,这种是直接控制裸线程,用start开启一个新线程,getname获得线程名字(规范的java程序必须指定有意义的线程名称,方便出错时回溯。)。不过这种方式太原始了,不好管理,要写很多代码管理线程创建结束这些,而且线程很昂贵,创建需要耗费大量的内存和时间,所以数量很不好管理,如果创建太多线程,会使系统饱和。。

所以一般要用线程池技术来管理线程。我之前看的《阿里java开发手册》规定强制线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。 因为可以减少在创建和销毁线程上所花的时间以及系统资源的开销,如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者 “过度切换”的问题。

Java5以后出现excutor框架,把任务和线程解耦,框架会封装好工作线程worker,并且管理他们,程序员只要定义好任务,调用api执行就行,不容易出错。现在基本上都是用excutor框架创建线程池管理线程。

Executor框架包括Executor接口(定义执行Runable 对象的executor方法),Executor的子接口是ExecutorService(定义了shutdown,submit这些更丰富的方法),而ThreadPoolExecutor类则实现了这两个接口,是一个底层的线程池实现类。

4.2 executor框架

#######1.线程池创建:
Threadpoolexecutor的构造方法需要传递
corePoolSize(线程池核心大小), maximumPoolSize(最大大小),
keepAliveTime(线程存活时间),timeunit( 单位如milliseconds),
runnableTaskQueue(任务队列,保存等待执行的任务,四种阻塞队列都可以用,ArrayBlockingQueue:有界队列
LinkedBlockingQueue:无界队列,静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
SynchronousQueue:一个不存储元素立刻交给线程执行的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
PriorityBlockingQueue:一个具有优先级得无限阻塞队列。),

还有threadFactory(线程工厂接口类,有默认的实现类,也可以传递自己实现的类,主要为了创建线程或线程池时能够指定有意义的线程名称,方便出错时回溯。),

RejectedExecutionHandler(拒接策略,这个接口的类是定义当线程池和队列都满了时候处理新任务的策略,默认是AbortPolicy,无法处理新任务时抛出异常,还有其他的实现类比如CallerRunsPolicy:调用者所在线程来运行任务。
DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
DiscardPolicy:不处理,丢弃掉。
也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化不能处理的任务。)

#######2.线程池提交:
除了能用execute 方法提交runnable对象,还可以使用submit方法,提交callable对象,区别就是callable对象可以定义返回一个future对象,get方法可以得到任务完成后返回的内容,可以设置等待的超时时间, cancel (boolean mayInterruptIfRunning) 取消任务的执行。参数指定是否立即中断任务执行,或者等等任务结束
boolean isCancelled () 任务是否已经取消,boolean isDone () 任务是否已经完成。
,如果用submit执行runnable对象得到的future对象内容为空。

#######3.线程池关闭:
线程池的shutdown或shutdownNow方法来关闭线程池,但是它们的实现原理不同,shutdown的原理是只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程。

调用shutdown后会等到所有任务结束后才算已经terminated。

#######4.Threadpoolexcutor的其他方法
shutDown():关闭执行器,在关闭前允许执行以前提交的任务执行器执行完。调用shutDown()后,再发送任务给Executor将会被拒绝,抛出RejectExecutionException异常。
shutdownNow() :立即关闭执行器,阻止等待任务启动,并试图停止当前正在执行的任务。返回等待执行的任务列表。
isShutdown():调用shutDown()后,返回true。
isTerminated():调用shutDown()后,并且执行器完成了关闭过程,返回true。
getPoolSize():获取当前线程池的线程数量
getActiveCount():获取线程池中活动线程的数量
getCompleteCount():获取线程池中完成的任务数。

#######5.线程池处理任务的规则是:
先看 corePoolSize,如果线程数量小于核心数量,即使线程池中有空闲线程,也会创建一个新的线程来执行新添加的任务;线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务后,还会无限循环获取工作队列里的任务来执行。

如果核心线程已满,工作队列未满,则将新添加的任务放到 workQueue 中,按照 FIFO 的原则依次等待执行

最后看最大数量,如果工作队列 workQueue 已满,但线程池中的线程数量小于 maximumPoolSize,则会创建新的线程来处理被添加的任务;
如果线程数量已经等于最大数量,让拒绝策略类 RejectedExecutionHandler来处理 。

#######6.工厂类Executors和使用原则
手工配置线程池比较麻烦,但是可以根据不同任务的性质配置不同的线程池,比较灵活。然后executor框架也有executors工厂类,提供一系列静态工厂方法直接生成配置好的线程池,比如:
public static ExecutorService newFixedThreadPool(int nThreads)创建固定数目线程的线程池,空闲线程立马移除
public static ExecutorService newCachedThreadPool()创建一个可缓存的线程池,没有可用线程时创建一个新线程并添加到池中。移除那些已有 60秒钟未被使用的线程。
public static ExecutorService newSingleThreadExecutor()创建一个单线程的Executor。

这些工厂方法本质上底层用的还是thread pool executor线程池,只不过根据不同情况定义好固定的参数。用起来方便一些,有点约定优于配置的感觉。一些资料也推荐用工厂模式创建线程池。像我看《thinking in java》时候executor这块只介绍了工厂方法创建的方式。但这种方式的问题就是不够灵活,不能说配置各种不同的线程池去处理不同性质的任务,可能适合并发要求不高或者新手使用。我看《java开发手册》里规范,线程池不允许使用 Executors 去创建,必须通过 ThreadPoolExecutor 的方式,这样 的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。不过想合理配置线程池还是挺复杂的事情,下面就是说说如何合理配置线程池。

#######7. 合理的配置线程池
要想合理的配置线程池,就必须首先分析任务特性,可以从以下几个角度来进行分析:

任务的性质:CPU密集型任务,IO密集型任务和混合型任务。
任务的优先级:高,中和低。
任务的执行时间:长,中和短。
任务的依赖性:是否依赖其他系统资源,如数据库连接。
任务性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务配置尽可能少的线程数量,如配置Ncpu+1个线程的线程池。IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。

优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。

执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。

依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU。

建议使用有界队列,有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点,比如几千。有一次我们组使用的后台任务线程池的队列和线程池全满了,不断的抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞住,任务积压在线程池里。如果当时我们设置成无界队列,线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然我们的系统所有的任务是用的单独的服务器部署的,而我们使用不同规模的线程池跑不同类型的任务,但是出现这样问题时也会影响到其他任务。

4.3 fork/join框架

Fork/Join框架是Java7提供的一个用于并行执行任务的框架, 可以把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。这个框架被设计用来处理一些特殊问题,比如解决可以用分治思想处理问题,像快排这些递归处理的问题。主要为了利用多核特性。可能机器处理器很多,但没有那么多并发的任务,这时候cpu使用的就不充分。用这个框架可以把一个大任务分成若干小任务交给不同线程并行处理,充分利用机器多核优势。而且有一个工作窃取(work-stealing)算法,可以令某个线程从其他队列里窃取任务来执行,这样能减少某些线程的空闲时间,充分利用每个线程。

使用和executor框架差不多,用ForkJoinPool 创建线程池(可以直接创建forkjoinpool对象,也可以用ForkJoinPool.commonPool()生成通用池),执行ForkJoinTask任务。需要继承它的子类
RecursiveAction:用于没有返回结果的任务。
RecursiveTask :用于有返回结果的任务。
ForkJoinTask与一般的任务的主要区别在于它需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果不足够小,就必须分割成两个子任务,fork就是执行子任务,又会进入compute方法,看看当前子任务是否需要继续分割成孙任务,如果不需要继续分割,则执行当前子任务并返回结果。join方法就是等待子任务执行完得到结果。

我第一次见这个joinfork框架是一片关于多线程快排的博客上。(对,就是在我之前写的快排优化那篇博客里提到的那个)
对fork/join框架的了解还不是很多,看有资料说好像和java8的并行流函数式编程这些有点关系,这部分的知识一直深入没学下去。(已经拖了好久还没看。。。我想最近应该还没时间看。。因为我要开始刷算法题了。。。)

2 Array list

ArrayList底层是使用一个Object类型的数组elementData来存放数据的,size表示List实际存放元素的数量。ElementDATA用transient关键字修饰。这么做的目的是不直接序列化全部elementData数组,因为可能数组刚扩容,数组很大,但实际存储元素没那么多。所以ArrayList的设计者将elementData设计为transient,然后重写writeObject方法,手动将数组里的每个元素逐个序列化,重写的writeObject源码里循环时是使用i<size而不是 i<elementData.length。并且为了防止元素个数比较大时,arraylist的空间浪费, 提供了 trimToSize()方法,而且扩容机制也牺牲了扩容次数,采用1.5倍,不是vector的两倍。扩容因为有数组复制操作,开销比较大,所以最好在构造方法里就指定一个合理的初始容量,然后也提供public 的方法ensureCapacity(int minCapacity)来增加 ArrayList 的容量。另外array list也同样实现了fail-fast机制,(迭代器里的所以方法会先判断modcount有没有变)。不过这个fail-fast 机制,只是一种错误检测机制,遇到有多线程的情况还是得根据实际情况用java.util.concurrent 包下的类或者外部同步ArrayList(List list = Collections.synchronizedList(new ArrayList(…));)。然后因为底层是数组实现,所以随机访问和末尾操作是O(1),在中间插入移除是O(n)。

Array list的线程安全处理
  1. 如果对数据同步的实时性要求不高,并且大量读操作的时候,可以使用Copy-On-Write容器。CopyOnWrite容器并发的读的时候不用加锁,这样速度也更快点。写的时候会加锁,然后复制一个新数组,把新元素添加进去,这个过程中还可以并发的读旧数据,等写操作完成时候会把引用指向新数组。不过因为有内存占用问题和数据一致性问题,所以用copyonweitearraylist的时候也要权衡,如果频繁写,造成频繁复制,增加垃圾回收次数,很不好。如果对读的效率要求不高。可以使用Collections的静态方法synchronizedList构造一个线程安全的array list。

arraylist不像hashmap,它在concurrent包里并没有特别高效的并发实现。copyonwritarraylist规避了读操作并发的瓶颈, 但写操作还会锁住整个List, CopyOnWriteArrayList并不算是一个通用的并发List。可能是因为很难去开发一个通用并且没有并发瓶颈的线程安全的List吧。像ConcurrentHashMap采用了锁分段技术去规避并发瓶颈,而arraylist很难有什么通用方法能避免锁住整个list。

CopyonwriteArraylist详细说明

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

CopyOnWrite并发容器用于读多写少的并发场景,比如定期更新的黑名单这些。

问题:
内存占用问题。

因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的GC。

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

预告

下次可能写一些算法题的解题?因为最近面了瓜子二手车,全程手写算法题,昨天面的新浪微博也问道了海量数据处理和布隆过滤器的内容(布隆过滤器早忘了。。。尴尬的沉默了好久。。。),感觉得复习一下这部分知识了。

或者总结下JVM垃圾回收,很早之前就看了《深入理解JVM的相关部分》,但一直没总结。怕下次被问到想不起来。。。


文末福利~~~哈哈,你是不是因为图片才点进来


上一篇下一篇

猜你喜欢

热点阅读