CPU通过给每个线程分配CPU时间片来实现多线程机制。时间片是CPU分配给各个线程的时间,这个时间非常短,一般是几十毫秒。 CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,但是 ,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
无锁并发编程
多任务处理数据时,可以用一些办法避免使用锁,如将数据ID按照HASH算法取模分段,不同的线程处理不同段的数据。
CAS算法
使用最少线程
任务很少时,避免创建不需要的线程
避免一个线程同时获取多个锁
避免一个线程在锁内同时占用多个资源,尽量保证一个锁只占用一个资源
尝试使用定时锁来替代内部锁机制
对于数据库锁,加锁和解锁必须在一个数据库连接中,否则会出现解锁失败的现象
资源限制是指在进行并发编程时,程序的执行速度受制于计算机硬件资源或软件资源。
考虑使用集群并行执行程序
使用资源池将资源复用,比如数据库连接池。
在并发编程中,synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,他在并发编程中保证了共享变量的 “可见性”。可见性的意思是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。volatile不会引起线程上下文的切换和线程调度。
Java中每个对象都可以作为锁,具体表现为以下3种形式:
synchronized原理(重要):
JVM基于进入和退出的Monitor对象来实现方法同步和代码块同步,但两者实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步使用另外一种方式实现的。
monitorenter指令在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束和异常处,JVM要保证每个monitorenter和monitorexit一一对应。当一个monitor被持有后,他将处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获取对象的锁。
synchronized用的锁是存在Java对象头里的,对象头组成如下所示:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashcode或锁信息等 |
32/64bit | Class Metadata Adress | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,锁一共有4中状态:级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头里的Mark Word里是否存储着当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否被置为1(表示当前是偏向锁):如果没有设置,则使用CAS竞争,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的关闭:偏向锁时默认启用的,但是在应用程序启动几秒钟之后才激活,可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定应用程序里所有的锁通常处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false。
轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到自己的锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
**轻量级做解锁:轻量级锁解锁时,会使用原子的CAS操作将Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差别 | 如果线程间存在竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问的同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了线程的响应速度 | 如果线程始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
处理器实现原理:略
Java实现原子操作:在Java中可以通过锁和循环CAS的方式来实现原子操作。
CAS实现原子操作的三大问题:
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理参数不会在线程之间共享。他们不会有内存可见性问题,也不会受内存模型的影响。
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度看:JMM定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是一个抽象概念,并不真实存在。
线程A和线程B之间要通信的话,必须要经过下面两个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读线程A之前更新过的共享变量。
volatile变量具有以下特性:
保证可见性
对一个volatile变量的读,总是能看到(任意线程对这个volatile变量最后的写入)
不保证原子性
对任意单个volatile变量的读写具有原子性,但类似于volatile++这种复合操作不具有原子性
禁止指令重排序
volatile写的内存语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读内存的语义如下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存读取共享变量并保存在线程对应的本地内存中。
当线程释放时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,同时从主内存中读取共享变量保存在本地内存中。
对比锁释放-获取的内存语义和volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义。
在一个进程里面可以创建多个线程,这些线程都拥有各自的计数器、堆栈、和局部变量等属性,并且能够访问共享变量的内存。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。
在Java进程中,通过一个整型变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int x)方法来修改优先级,默认优先级是5。设置优先级时,针对频繁阻塞(I/O)的线程需要设置较高的优先级,而偏重计算的线程需要设置较低的优先级。
状态名称 | 说明 |
---|---|
NEW | 初始状态,线程被构建但是还没有调用start()方法 |
RUNNABLE | 运行状态,Java线程将操作系统的就绪和运行两种状态统称为“运行中” |
BLOCKED | 阻塞状态,表示线程阻塞于锁 |
WAITING | 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断) |
TIME_WAITING | 超时等待状态,该状态不同与WAITING,它是可以在指定的时间自行返回的 |
TERMINATED | 终止状态,表示当前线程已经执行完毕 |
Daemon线程是一种支持性线程,它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。
启动线程:线程对象在初始化之后,调用start()方法就可以启动这个线程。
start()和run()方法的区别:xxx
停止线程:中断可以理解为线程的一个标识位属性,他表示一个运行中的线程是否被其他线程进行了中断操作。其他线程调用该线程的interrupt()方法对其进行中断操作。中断操作时一种简便的线程间交互方式,这种交互方式最适合用来取消或停止任务。除了中断以外,还可以利用Boolean变量来控制是否需要停止任务并终止该线程,示例代码如下:
public class ShutDownDemo {
public static void main(String[] args) throws InterruptedException {
Runner runner = new Runner();
Thread countthread = new Thread(runner, "countthread");
countthread.start();
TimeUnit.SECONDS.sleep(1);
countthread.interrupt();
Runner runner1 = new Runner();
countthread = new Thread(runner1, "countthread");
countthread.start();
TimeUnit.SECONDS.sleep(1);
runner1.cancel();
}
}
class Runner implements Runnable{
private long i;
private volatile boolean on = true;
@Override
public void run() {
while (on && !Thread.currentThread().isInterrupted()){
i++;
}
System.out.println("count i =" + i);
}
public void cancel(){
on = false;
}
}
这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程终止。
volatile关键字可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需从共享内存中获取,而对他的任何修改必须同步刷新到共享内存,他们保证所有线程对变量访问的可见性。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
等待通知机制的相关方法是Java对象都具备的,因为这些方法被定义在所有对象的超类Object上,方法如下所示
方法名 | 描述 |
---|---|
notify() | 通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁 |
notifyAll() | 通知所有等待在该对象上的线程 |
wait(long,int) | 调用该方法的线程进入WAITING状态,只有等待另外线程的通知或中断才会返回,需要注意,调用wait()方法后,会释放对象的锁 |
等待通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。
如果一个线程A执行了Thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(共享锁除外)。Java程序靠Lock接口和synchronized关键字实现锁功能。
Lock接口的使用方式如下:
Lock reentrantLock = new ReentrantLock();
reentrantLock.lock();
try {
// do...
} finally {
reentrantLock.unlock();
}
在final块中释放锁,目的是保证在获取到锁之后,最终能够被释放。
不要将获取锁的过程写在try块中,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故释放。
Lock接口提供的锁主要特性如下(区别于synchronized关键字):
待补充。。。
Lock接口的实现类基本都是通过聚合了一个同步器的子类来完成线程访问控制的。
队列同步器是用来构建锁或者其他同步组件的基础框架,他使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁时面向使用者的,他定义了使用者于锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现,他简化了锁的实现方式,屏蔽了同步状态管理器、线程的排队、等待与唤醒等底层操作。
重入锁,顾名思义就是支持重进入的锁,他表示该线程能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时公平和非公平性的选择。
synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
锁获取的公平性问题: 如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁时公平的。反之是不公平的。公平锁的获取,也就是等待时间最长的线程最优先获取锁,也可以说获取锁的顺序的。
事实上,公平锁的效率往往没有非公平锁的效率高,公平锁能够减少“饥饿”发生的概率,等待时间越久的请求越是能够得到优先满足。
下面着重分析ReentrantLock是如何实现重进入和公平性获取锁的特性:
1、实现重进入
重进入是指任意线程在获取锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决以下两个问题:
ReentrantLock是通过组合自定义同步器来实现锁的释放和获取。如果获取锁的线程再次请求获取锁,则将同步状态值进行增加并返回true,表示获取锁成功。同样,ReentrantLock在释放锁时会减少同步状态值。
之前提到的ReentrantLock是排它锁,即在同一时刻只允许一个线程访问。而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁,一个写锁,通过分离读锁和写锁,是的并发性相比其他一般的排他锁有了很大提升。
一般情况下,读写锁的性能会比其他排他锁好,因为大多数场景读是多余写的。Java中提供读写锁的是ReentrantReadWriteLock,它提供的特性如下:
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
重进入 | 该锁支持重进入 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成读锁 |
ReentrantReadWriteLock展示内部工作状态的方法:
方法名称 | 描述 |
---|---|
int getReadLockCount() | 返回当前读锁被获取的次数。该次数不等于获取读锁的线程数,例如,仅一个线程,它连续获取了n次读锁,那么占据读锁的线程数是1,但该方法返回n |
int getReadHoldCount() | 返回当前线程获取读锁的次数 |
boolean isWriteLocked() | 判断写锁是否被获取 |
int getWriteHoldCount() | 返回当前写锁被获取的次数 |
CurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁,在CurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个CurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与他对应的Segment锁。
get操作:Segment的get操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素。
public V get(Object key){
int hash = hash(key.hashCode());
return segmentFor(hash).get(key,hash);
}
get操作的高效在于整个get操作不需要加锁,除非读到空值才会加锁重读。那么他是如何做到不加锁的呢?原因是他的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前Segment大小的count字段和用于存储值得HashEntry的value,此举保证了变量在线程之间的可见性,能够被多线程同时读取,并且保证不会读取到过期值。
put操作:put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作经过两个步骤:第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。
是否需要扩容
Segment扩容的操作比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经达到容量的,如果到达了就扩容,但是很有可能扩容之后没有新元素加入,这时HashMap就进行了一次无效的扩容。
如何扩容
在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器扩容,而只对某个segment进行扩容。
size操作:ConcurrentHashMap的做法是先尝试两次通过不锁住的Segment的方法来统计各个Segment大小,如果统计过程中,容器的count发生了变化,则再采用加锁(把所有Segment的put、remove、和clean方法全部锁住)的方式来统计所有Segment的大小。那么ConcurrentHashMap是如何判断在统计的时候容器发生了变化呢?使用了modCount变量,在put、remove、和clean方法里操作元素钱都会讲modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器大小是否发生了变化。
阻塞队列是一个支持两个附件操作的队列。这两个附加的操作支持阻塞的插入和异常方法。
在阻塞队列不可用时,这两个附加操作提供了4种处理方式:
CountDownLatch允许一个或多个线程等待其他线程完成操作。
假如有这样一个需求:我们需要解析一个Excel里多个sheet数据,此时可以考虑使用多线程。每个线程解析一个sheet里的数据,等到所有的sheet都解析完之后,程序需要提示解析完成。在这个需求中,要实现主线程等待所有线程完成sheet的解析操作,最简答的方法是使用join()方法,join()用于让当前执行线程等待join线程执行结束。其实现原理是不停地检查join线程是否存活,如果join线程存活则让当前线程永远等待,其中wait(0)表示永远等待下去。
//待补充详细
在JDK1.5之后的并发包中提供的CountDownLatch也可以实现join的功能,并且比join的功能更多。
CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用CountDownLatch的countDown方法时,N就会减一,CountDownLatch的await方法会阻塞当前线程,直到N变成零,由于countDown方法可以用在任何地方,所以这里说的N个点,可以是N个线程,也可以是1个线程里的N个执行步骤,用在多个线程时,只需要把这个CountDownLatch的引用传递到线程里即可。
如果有某个解析sheet的线程处理的会比较慢,我们不可能一直让主线程一直等待,所以可以使用另一个带执行时间的await方法-await(long time,TimeUnit unit),这个方法等待特定时间后就不会再阻塞当前线程。