目录
一、线程
1. 概念
进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程中可以启动多个线程。比如在Windows系统中,一个运行的exe就是一个进程。
线程是指进程中的一个执行流程,一个进程中可以运行多个线程。比如java.exe进程中可以运行很多线程。线程总是属于某个进程,进程中的多个线程共享进程的内存。
2. 类型
java线程一共分成两种,用户线程和守护线程序。默认是用户线程,可以通过setDaemon方法设置是否为守护线程。守护线程与用户线程的区别就是当JVM实例中所有非守护线程都结束时,守护线程也将会结束。典型的应用就是GC(java垃圾回收器)。
3. 常用方法
-
start()
启动一个线程,此时线程处于运行(RUNNABLE)状态 -
join()/join(long millis)
调用该方法等待线程结束。当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的) -
sleep(long millis)
调用sleep()方法让线程进入休眠状态后,线程休眠会交出CPU,让CPU去执行其他的任务。sleep()方法并不会释放锁,即当前线程持有某个对象锁时,即使调用sleep()方法其他线程也无法访问这个对象。 -
interrupt()
调用Thread类的interrupted()方法,其本质只是设置该线程的中断标志,将中断标志设置为true,并根据线程状态决定是否抛出异常。它不会中断一个正在运行的线程。 -
stop()/resume()/suspend()
stop方法用于强制终止一个线程的执行,resume方法用于恢复线程的执行,suspend方法用于暂停线程的执行。 这三个API都已经过时,不推荐使用。
不推荐使用 suspend() 去挂起线程的原因,是因为 suspend() 在导致线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行 resume() 方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果 resume() 操作出现在 suspend() 之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。而且,对于被挂起的线程,它的线程状态居然还是 Runnable。
而stop()方法会解除由线程获得的所有锁,被调用的stop()方法使得线程即 使在同步方法中也要停止,这就造成了数据的不完整性。建议通过volatile类型的标志位来终止线程。
-
yield()
线程让步,暂停当前正在执行的线程对象,并执行其他线程。当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。 -
holdsLock(Object obj)
检测一个线程是否拥有锁,它返回true当且仅当当前线程拥有某个具体对象的锁
3. 状态转换
java中定义了线程的以下六种状态:
- 初始(NEW): 新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE): Java线程中将就绪(ready)和运行中(running)两种状态统称为“运行”。
线程对象创建后,调用该对象的start()方法,该状态处于就绪状态(ready),等待被线程调度选中,获取CPU的使用权;在获得CPU时间片后变为运行中状态(running)。 - 阻塞(BLOCKED): 表示线程阻塞于锁。
- 等待(WAITING): 进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING): 该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED): 表示该线程已经执行完毕。
状态转换图:
- Thread 类中的start() 和 run() 方法有什么区别?
当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启 动,start()方法才会启动新线程。- 为什么wait、notify、notifyAll方法不在Thread类里面?
wait、notify、notifyAll方法是属于Object类的。Thread是Object的子类。
JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。且调用某个对象这些方法,当前线程必须拥有这个对象锁。- Thread.sleep、Object.wait、LockSupport.park 区别?
https://blog.csdn.net/u013332124/article/details/84647915
4. 扩展
-
FutureTask
FutureTask一个可取消的异步计算,FutureTask 实现了Future的基本方法,可以查询计算是否已经完成,并且可以获取计算的结果。结果只可以在计算完成之后获取,get方法会阻塞当计算没有完成的时候,一旦计算已经完成,那么计算就不能再次启动或是取消。Runnable接口和Callable接口的区别:
类别 Runnable Callable 版本 java1.1新增 java1.5新增 方法 Callable规定的方法是call() Runnable规定的方法是run() 返回值 Callable的任务执行后可返回值 void 异常 call方法可以抛出异常 run方法不可以 检测 Callable任务可以拿到一个Future对象,通过Future对象可以了解任务执行情况,可取消任务,还可获取执行结果。 无 加入线程池 execute() submit()
二、线程池
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。 例如,线程数一般取cpu数量+2比较合适,线程数过多会导致额外的线程切换开销。
1.类图
Executor是线程池的鼻祖类,它有两个子类是ExecutorService和ScheduledExecutorService,而ThreadPoolExecutor和ScheduledThreadPoolExecutor则是真正的线程池,我们的任务将被这两个类交由其所管理者的线程池运行。
最为原始的Executor只有一个方法execute,它接受一个Runnable类型的参数,意思是使用线程池来执行这个Runnable,可以发现Executor不提供有返回值的任务。ExecutorService继承了Executor,并且极大的增强了Executor的功能,不仅支持有返回值的任务执行,而且还有很多十分有用的方法来为你提供服务,ScheduledExecutorService继承了ExecutorService,并且增加了特有的调度(schedule)功能。
2. 线程池创建
Executors是jdk里面提供的创建线程池的工厂类,它默认提供了5种(JDK1.8新增1种)常用的线程池应用,而不必我们去重复构造。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public static ExecutorService newWorkStealingPool(int parallelism) {
return new ForkJoinPool
(parallelism,
ForkJoinPool.defaultForkJoinWorkerThreadFactory,
null, true);
}
但是我们可以从Executors源码发现,创建线程池最终使用的都是ThreadPoolExecutor和ScheduledThreadPoolExecutor类的,而ScheduledThreadPoolExecutor继承ThreadPoolExecutor类的。
阿里发布的 Java开发手册中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
ThreadPoolExecutor的构造函数中包含以下几个参数:
- corePoolSize:核心线程大小,当新任务来时,如果池中的线程数小于corePoolSize,就创建新的线程来执行这个任务,即使当前池中有空闲的线程,直到线程数达到corePoolSize;
- maximumPoolSize:池中最大线程数,当运行的线程达到corePoolSize,此时还有任务来时,先把任务放到队列workQueue中,如果队列也满了,就继续创建线程,直到线程中达到maximumPoolSize;
- keepAliveTime:超过corePoolSize的线程如果空闲,最多存活的时间;
- unit:时间的单位;
- workQueue:等待执行的任务队列,常用的任务队列有:
- SynchronousQueue:同步队列,是一个特殊的BlockingQueue,它没有容量(这是因为在SynchronousQueue中,插入将等待另一个线程的删除操作,反之亦然)。
- LinkedBlockingQueue:无界,默认大小65536(Integer.MAX_VALUE),当大量请求任务时,容易造成内存耗尽。
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,构造函数一定要传大小,FIFO(先进先出)
- PriorityBlockingQueue: 优先队列,无界。
- DelayedWorkQueue:这个队列接收到任务时,首先先入队,只有达到了指定的延时时间,才会执行任务(ScheduledThreadPoolExecutor采用这种队列)。
- threadFactory:创建线程的工厂
- handler:当池中线程达到最大且任务队列也满了的时候对新来的任务的处理策略,策略有如下:
- AbortPolicy:默认策略,表示无法处理新来的任务,并抛出个RejectedExecutionException异常
- CallerRunsPolicy:使用调用者自己的线程处理新任务,这样也减慢了任务提交的速度
- DiscardPolicy:处理不了了,丢掉。。。
- DiscardOldestPolicy:丢弃任务队列中队头的任务,尝试重新执行当前任务
3. 线程池类型
Executors创建线程池的工厂类,它默认提供了5种常用的线程池
- newSingleThreadExecutor():创建单个线程的线程池
- newFixedThreadPool(int nThreads):创建固定大小的线程池
- newCachedThreadPool():创建一个可缓存的线程池,新任务到来时先看池中有没有空闲线程,有就用,没有就新建,线程空闲超过60秒就会被销毁
- newSingleThreadScheduledExecutor():单个定时执行的线程池
- newWorkStealingPool(int parallelism):JDK1.8新增,创建一个具有抢占式操作的线程池。采用用的是 ForkJoinPool 类,它是一个并行的线程池,参数中传入的是一个线程并发的数量。这个线程池不会保证任务的顺序执行,也就是 WorkStealing 的意思,抢占式的工作。
三、锁
1. synchronized
1.1 原理
synchronized是java提供的一种内置的锁机制。通过synchronized关键字同步代码块。线程在进入同步代码块之前会自动获得锁,并在退出同步代码块时自动释放锁。内置锁是一种互斥锁。JVM通过将monitorenter指令插入同步代码块的开始位置,monitorexit指令插入同步代码块的结束位置来实现同步的(修饰方法时通过ACC_SYNCHRONIZED标志位实现,原理类似)。
- monitorenter
java中每个对象有一个监视器锁(monitor)。当monitor被占用时就处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 如果线程已经占有该monitor,只是重新进入(可重入锁),则进入monitor的进入数加1。
- 如果其他线程已经占用monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
- monitorexit
执行monitorexit的线程必须是object所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,则线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
1.2 用法
- synchronized修饰普通同步方法。此时锁的是当前实例的对象。
public synchronized void synMethod(){
//do something
}
- synchronized修饰静态同步方法。此时锁的是类的class对象。
public static synchronized void synMethod(){
//do something
}
- synchronized修饰同步代码块。此时锁的是括号内的对象。
public void synMethod0() {
synchronized (this){
//do something
}
}
Object lock = new Object();
public void synMethod1(){
synchronized (lock){
//do something
}
}
1.3 锁优化
上面我们提到了monitor,java中任何一个对象都有一个monitor对象与之关联,monitor就存在于Java对象头里。在JVM中,对象在内存中的布局分为三块区域:对象头、实例变量和填充数据。如下:
- 实例变量:存放类的属性数据信息。
- 填充数据:用于保证对象8字节对齐。
- 对象头:JVM采用2个字宽(Word)存储对象头,若对象为数组则采用3个字宽来存储。在32位虚拟机中1字宽等于4字节,64位虚拟机中1字宽等于8字节。其结构说明如下表:
长度 | 对象头结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
32/32bit | Array length | 数组的长度(若当前对象为数组) |
对象头中的Mark Word里,默认存储对象的HashCode、分代年龄和锁标记位。 32位JVM中,Mark Word的默认存储结构如下:
25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|
对象HashCode | 对象分代年龄 | 0 | 01 |
在运行期间,根据Mark Word里锁标志位的变化,Mark Word的数据也会发生变化。下面列举了32位JVM下,4种锁状态时Mark Word的存储结构:
在重量级锁状态时,其中指向互斥量的指针指向的就是monitor对象的起始地址。
在synchronization优化之前,只有重量级锁一种。而重量级涉及到Mute lock(系统互斥锁)等特权指令,这个时候就会存在操作系统用户态和内核态的转换(进程初始都运行于用户空间,此时即为用户态;但是当它调用系统调用执行某些操作时,例如 I/O调用,此时需要进入内核中运行,此时进程处于内核态),这种切换就带来了大量的系统资源消耗,这就是在synchronized未优化之前,效率低的原因。
-
锁升级
JDK1.6对其优化之后,线程首次获得对象时,将处于偏向锁状态,随着竞争的升级,锁可以逐渐升级。锁的升级流程为:
偏向锁–>轻量级锁–>重量级锁 (只能升级不能降级)-
偏向锁
引入偏向锁的原因就是在大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获取锁的代价更低,引入偏向锁,减少不必要的CAS操作。(可通过-XX:-UseBiasedLocking禁用偏向锁)当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则直接执行同步代码;
如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,
如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;
如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象;那么将锁对象状态设为无锁状态,重新偏向新的线程(线程2)。 -
轻量级锁
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈存储的锁记录空间,然后使用CAS把对象头中的内容替换为线程1存储的锁记录的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。
-
重量级锁
重量级锁把除了拥有锁的线程都阻塞,等待之前线程执行完成并唤醒自己。
-
- CAS:Compare and Swap,即比较再交换。它是一条CPU并发原语,原语的执行必须是连续的,在执行过程中不允许被中断,不会造成所谓的数据不一致性问题。jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。CAS具体包括三个参数:当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。
- CAS缺点:
a. 循环时间开销很大
b. 只能保证一个共享变量的原子操作
c. 引来ABA问题及解决方案(比如说一个线程1从内存位置V中取出A,这时候另一个线程2也从内存中取出A,并且线程2进行了一些操作将值变成了B,后又将V位置的数据变成A,这时候线程1进行CAS作发现内存中仍然是A,然后线程1操作成功。可以通过版本号解决)- 自旋
锁的自旋是指尝试获取锁失败的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
-
适应性自旋
从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。 -
锁粗化
就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:stringBuffer.append("a"); stringBuffer.append("b"); stringBuffer.append("c");
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
- 锁消除
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
2. lock
在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖java.util.concurrent.AbstractQueuedSynchronizer(AQS)类,ReentrantLock把所有Lock接口的操作都委派到一个Synce内部类上,Sync又有两个子类NonfairSync和FairSync,是为了支持公平锁和非公平锁而定义。
2.1 AbstractQueuedSynchronizer(AQS)
AQS提供了一种实现阻塞锁和一系列依赖FIFO等待队列的同步器的框架,ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier等并发类均是基于AQS来实现的,具体用法是通过继承AQS实现其模板方法,然后将子类作为同步组件的内部类。
AQS基本框架如下图所示:
AQS维护了一个volatile语义(支持多线程下的可见性)的共享资源变量state和一个FIFO线程等待队列(多线程竞争state被阻塞时会进入此队列)。
资源的共享方式分为2种:
- 独占式(Exclusive)
只有单个线程能够成功获取资源并执行,如ReentrantLock。 - 共享式(Shared)
多个线程可成功获取资源并执行,如Semaphore/CountDownLatch等。
AQS将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。AQS也支持子类同时实现独占和共享两种模式,如ReentrantReadWriteLock。
2.2 ReentrantLock
ReentrantLock是Lock接口的实现类,synchronized属于隐式锁,即锁的持有与释放都是隐式的,我们无需干预,而ReentrantLock是显式锁,即锁的持有和释放都必须由我们手动编写。ReentrantLock作用与synchronized关键字相当,但比synchronized更加灵活。
Lock lock = new ReentrantLock();
lock.lock();
try{
//临界区......
}finally{
lock.unlock();
}
锁的创建:
非公平锁(默认)
final ReentrantLock lock = new ReentrantLock();
final ReentrantLock lock = new ReentrantLock(false);
公平锁
final ReentrantLock lock = new ReentrantLock(true);
- 非公平锁加锁过程
多个线程调用lock()方法, 如果当前state为0, 说明当前没有线程占有锁, 那么只有一个线程会CAS获得锁, 并设置此线程为独占锁线程。那么其它线程会调用acquire方法来竞争锁(后续会全部加入同步队列中自旋或挂起)。
当有其它线程A又进来想要获取锁时, 恰好此前的某一线程恰好释放锁, 那么A可能会在同步队列中所有等待获取锁的线程之前抢先获取锁。也就是说所有已经在同步队列中的尚未被 取消获取锁 的线程是绝对保证串行获取锁,而其它新来的却可能抢先获取锁。这就是不公平的原因。 - 公平锁的加锁过程
对比非公平锁,新来的线程没有插队的机会,所有来的线程必须扔到队列尾部, 只有队列为空或着本身就是队列头节点,那么才可能成功。保证了公平锁的获取串行化。 - 锁的释放
公平锁和非公平锁的释放逻辑是一致的 都是通过sync.release(1),release之后还要调用unparkSuccessor() 方法唤醒后继结点。
Synchronized与Lock对比:
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的释放 | 已获取锁的线程执行完同步代码,释放锁 ;线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,不然容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方式,可以尝试获得锁,线程可以不用一直等待 |
锁状态 | 无法判断 | 可以判断 |
锁类型 | 可重入 不可中断 非公平 | 可重入 可判断 可公平(两者皆可) |
性能 | 适用于少量同步 | 适用大量同步 |
3. ReadWriteLock
ReadWriteLock是一个接口,主要有两个方法,如下:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
- ReetrantReadWriteLock实现了ReadWriteLock接口,实现了公平模式和非公平模式,可重入锁。
- ReetrantReadWriteLock读写锁的实现中,读锁使用共享模式;写锁使用独占模式,换句话说,读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。
- ReetrantReadWriteLock读写锁的实现中,需要注意的,当有读锁时,写锁就不能获得;而当有写锁时,除了获得写锁的这个线程可以获得读锁外,其他线程不能获得读锁
4. volatile
volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。
特性:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
- 禁止进行指令重排序。(实现有序性)
- volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
5. Threadlocal
ThreadLocal是除了加锁这种同步方式之外的一种保证一种规避多线程访问出现线程不安全的方法。它提供线程本地变量,如果创建一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个副本,在实际多线程操作的时候,操作的是自己本地内存中的变量,从而规避了线程安全问题。
6. Hashtable和ConcurrentHashmap
Hashtable和ConcurrentHashmap都是线程安全集合,但是实现方法不一样。
- Hashtable
Hashtable通过使用synchronized修饰方法的方式来实现多线程同步,因此,Hashtable的同步会锁住整个数组。在高并发的情况下,性能会非常差。 - ConcurrentHashmap
JDK1.8之前,ConcurrentHashMap采用了分段锁来提高在并发情况下的效率。ConcurrentHashMap将Hash表(Hash表结构详见:https://editor.csdn.net/md/?articleId=97106911)默认分为16个桶(每一个桶可以被看作是一个Hashtable),大部分操作都没有用到锁,而对应的put、remove等操作也只需要锁住当前线程需要用到的桶,而不需要锁住整个数据。采用这种设计方式以后,在大并发的情况下,同时可以有16个线程来访问数据。显然,大大提高了并发性。
JDK1.8,由于ConcurrentHashMap通过使用Synchronized对链表的头节点加锁,锁粒度更小。而创建头节点是CAS操作,更加提高了并发性。
JDK1.81.8中ConcurrentHashmap为什么是Synchronized,而不是ReentranLock?
- 减少内存开销
假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。- 获得JVM的支持
可重入锁毕竟是API这个级别的,后续的性能优化空间很小。
Synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得Synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。
7. 常用同步类
-
Semaphore :信号量是一类经典的同步工具。信号量通常用来限制线程可以同时访问的(物理或逻辑)资源数量。
-
CountDownLatch: 典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。
-
CyclicBarrier :它能阻塞一组线程直到某个事件的发生。CyclicBarrier可以使一定数量的线程反复地在栅栏位置处汇集。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达栅栏位置,那么栅栏将打开,此时所有的线程都将被释放,而栅栏将被重置以便下次使用。
-
Phaser: 一种可重用的同步屏障,功能上类似于CyclicBarrier和CountDownLatch,但使用上更为灵活。它把多个线程协作执行的任务划分为多个阶段,编程时需要明确各个阶段的任务,每个阶段都可以有任意个参与者,线程都可以随时注册并参与到某个阶段。
-
Exchanger :Exchanger类可用于两个线程之间交换信息。可简单地将Exchanger对象理解为一个包含两个格子的容器,通过exchanger方法可以向两个格子中填充信息。当两个格子中的均被填充时,该对象会自动将两个格子的信息交换,然后返回给线程,从而实现两个线程的信息交换。
-
Atomic: Atomic包是java.util.concurrent下的另一个专门为线程安全设计的java的包,包含多个原子性操作的类。可以对基本数据,数组中的基本数据,对类中的基本数据进行操作(通过CAS实现)。
四、扩展
1.生产者消费者模式
https://blog.csdn.net/qq_41247433/article/details/79434202
2. fork/join框架
https://www.cnblogs.com/cjsblog/p/9078341.html
3. 常用单例实现
https://blog.csdn.net/mnb65482/article/details/80458571
4. 常见并发面试题
https://www.cnblogs.com/xiaowangbangzhu/p/10443289.html
https://blog.csdn.net/u010796790/article/details/52194646
参考链接:
https://blog.csdn.net/pange1991/article/details/53860651
https://www.cnblogs.com/riskyer/p/3263032.html
https://blog.csdn.net/tongxuexie/article/details/80145663
https://blog.csdn.net/u013332124/article/details/84647915
https://www.sohu.com/a/273749069_505779/
https://blog.csdn.net/tongdanping/article/details/79647337
https://www.jianshu.com/p/0f876ead2846
https://www.jianshu.com/p/ccfe24b63d87