问题一: 多线程一定快吗?
答案是不一定,这是因为存在线程有创建和上下文切换的开销。
我们可以通过命令vmstat来测试上下文切换的次数,下面是利用vmstat测试上下文切换的示例:
CS(Content Switch)表示上下文切换的次数,从上面的测试结果中我们可以看到,上下文 每1秒切换1000多次。
**问题二:如何减少上下文切换? **
无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用锁(如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据)
- Peterson算法(控制两个进程访问一个共享的单位用户资源而不发生访问冲突)
- ConcurrentHashMap用桶粒度的锁,避免了put和get中对整个map的锁定,尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见的。
- CAS算法。Java的atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
定义: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的变量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是a++和a +=1就不是原子性操作。
Java中的原子性操作包括:
定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。
synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
定义:即程序执行的顺序按照代码的先后顺序执行。
Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,自然就保证了有序性。
思考题:sync 已经可以保证可见性了,为什么DCL还要加volatile关键字?
new 对象的过程不是原子操作 先申请内存 初始化 再关联引用(new,dup,invokespecial),volatile是为了使这3个指令不发生重排序,如果乱序的话,别的线程就有可能存在一个时间窗口,拿到非空,但不完整的对象。
大家想一下这段程序的输出结果是多少?
也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。
原因是volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。
Java中的每一个对象都可以作为锁。具体表现 为以下3种形式。
对于普通同步方法,锁是当前实例对象
public synchronized void add(int value){
this.count += value;
}
对于静态同步方法,锁是当前类的Class对象。
public static synchronized void add(int value){
count += value; // count位静态变量
}
对于同步方法块,锁是synchronized括号里配置的对象。
public void add(int value){
synchronized(this){
this.count += value;
}
}
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
1、可重入性
一个对象往往有多个方法,这些方法有的是同步的,有的是非同步的,那么如果一个线程已经获得了某个对象的锁并进入了其某个同步方法,而这个同步方法中还需要调用同一实例的另一个同步方法,是否需要重新竞争锁?
这对于某些锁来说,是需要重新竞争锁的,但是我们的 synchronized 是「可重入的」,也就是说,如果当前线程获得了某个对象的锁,那么该对象的所有方法都是可以无需竞争锁式调用的。
2、可见性
但是说实话,解决内存可见性而使用 synchronized 代价太高,需要加锁和释放锁,甚至还需要阻塞和唤醒线程,我们一般使用关键字 volatile 直接修饰在变量上就可以了,这样对于该变量的读取和修改都是直接映射内存的,不经过线程本地私有工作内存的。
升级过程可简单的理解为:
偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁就是指对象头中的 mark word
存储了当前线程的 ID;
检查 mark word
中的线程 id 是不是当前线程,如果是当前线程,进入同步代码块;不是就进行锁升级;
mark word
更新为当前线程锁记录的指针,如果成功,表示持有锁,执行同步块,如果失败执行步骤 3monitor
的指针,然后继续执行代码块(1)使用循环CAS实现原子操作
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。
(2)unsafe工具类
从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子 方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和 自减1。
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。
从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);
private static AtomicInteger atomicInt = new AtomicInteger(100);
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);
public static void main(String[] args) throws InterruptedException {
Thread intT1 = new Thread(new Runnable() {
@Override
public void run() {
atomicInt.compareAndSet(100, 101);
atomicInt.compareAndSet(101, 100);
}
});
Thread intT2 = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
boolean c3 = atomicInt.compareAndSet(100, 101);
System.out.println(c3); // true
}
});
intT1.start();intT2.start();intT1.join();intT2.join();
}
private static AtomicInteger atomicInt = new AtomicInteger(100);
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);
public static void main(String[] args) throws InterruptedException {
Thread refT1 = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
}
});
Thread refT2 = new Thread(new Runnable() {
@Override
public void run() {
int stamp = atomicStampedRef.getStamp();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
}
boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
System.out.println(c3); // false
}
});
refT1.start();refT2.start();refT1.join();refT2.join();
}
在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构,结构变得复杂了,但是效率也变的更高效。当一个值中要存储到Map的时候会根据Key的值来计算出他的hash,通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储 在Object源码分析中解释过,但是这样如果链表过长来的话,HashMap会把这个链表转换成红黑树来存储。
HashMap是非同步的,在JDK1.8之前,由于在向链表中插入数据时,采用的是头插法,多线程下,当链表发生扩容时,put操作会导致链表产生环,出现死循环,CPU100%
虽然jdk8后HashMap在多线程情况下扩容时不会出现死循环的情况,但是在多线程情况下仍不推荐使用HashMap。因为HashMap中的put/get方法并未加锁,这意味着无法保证上一秒put的值,下一秒取到的是原值,所以线程安全无法保证。多线程下推荐使用ConcurrentHashMap。
Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。
Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。
Hashtable同样实现了Serializable接口,它支持序列化,实现了Cloneable接口,能被克隆。
Hashtable 中的方法是Synchronize的,而HashMap中的方法在缺省情况下是非Synchronize的。在多线程并发的环境下,可以直接使用Hashtable,不需要自己为它的方法实现同步,但使用HashMap时就必须要自己增加同步处理。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(...));
Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize
在jdk1.8之前ConcurrentHashMap在传统HashEntry之前增加了一个segment数组。Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,Segment数组中每一个元素就是一把锁,每一个Segment元素存储的是HashEntry数组+链表。
在jdk1.8开始,ConcurrentHashMap是由CAS和Synchronized的方式去实现高并发下的线程安全。
CopyOnWriteArrayList的设计是对于线程安全容器Vector使用中出现问题的一种优化。解决了Vector在读写复合操作中因为使用了synchronized关键字的性能问题。
CopyOnWrite 的核心思想是利用读写分离,因为高并发往往是读多写少。进行读操作的时候,不加锁以保证性能;对写操作则要加锁,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可见性。
既然CopyOnWrite在进行写操作的时候要进行数组的复制,性能和内存开销比较大,因此它更适用于读多写少的操作,例如缓存。
进行写操作时尽量使用使用CopyOnWriteArrayList.addAll方法,来避免多次反复写入
public class CopyOnWriteMap<K, V> implements Map<K, V>, Cloneable {
private volatile Map<K, V> internalMap;
public CopyOnWriteMap() {internalMap = new HashMap<K, V>();}
public V put(K key, V value) {
synchronized (this) {
Map<K, V> newMap = new HashMap<K, V>(internalMap);
V val = newMap.put(key, value);
internalMap = newMap;
return val;
}
}
public V get(Object key) {return internalMap.get(key);}
public void putAll(Map<? extends K, ? extends V> newData) {
synchronized (this) {
Map<K, V> newMap = new HashMap<K, V>(internalMap);
newMap.putAll(newData);
internalMap = newMap;
}
}
}
在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。
JDK 提供了 7 个阻塞队列。分别是:
DelayQueue:一个使用优先级队列实现的无界阻塞队列。
SynchronousQueue:一个不存储元素的阻塞队列。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
ArrayBlockingQueue 是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。**默认情况下不保证访问者公平的访问队列,所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可以按照阻塞的先后顺序访问队列,即先阻塞的生产者线程,可以先往队列里插入元素,先阻塞的消费者线程,可以先从队列里获取元素。**通常情况下为了保证公平性会降低吞吐量。我们可以使用以下代码创建一个公平的阻塞队列:
ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true);
访问者的公平性是使用可重入锁实现的,代码如下:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0) throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
LinkedBlockingQueue 是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。
PriorityBlockingQueue 是一个支持优先级的无界队列。默认情况下元素采取自然顺序排列,也可以通过比较器 comparator 来指定元素的排序规则。元素按照升序排列。
SynchronousQueue 是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。SynchronousQueue 可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景, 比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue 的吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue。
DelayQueue 是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。我们可以将 DelayQueue 运用在以下应用场景:
LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和 transfer 方法。
transfer 方法。如果当前有消费者正在等待接收元素(消费者使用 take() 方法或带时间限制的 poll() 方法时),transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的 tail 节点,并等到该元素被消费者消费了才返回。
如果队列是空的,消费者会一直等待,当生产者添加元素时候,消费者是如何知道当前队列有元素的呢?如果让你来设计阻塞队列你会如何设计,让生产者和消费者能够高效率的进行通讯呢?让我们先来看看 JDK 是如何实现的。
使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看 JDK 源码发现 ArrayBlockingQueue 使用了 Condition 来实现,代码如下:
private final Condition notFull;
private final Condition notEmpty;
public ArrayBlockingQueue(int capacity, boolean fair) {
// 省略其他代码
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await();
insert(e);
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0) notEmpty.await();
return extract();
} finally {
lock.unlock();
}
}
private void insert(E x) {
items[putIndex] = x;
putIndex = inc(putIndex);
++count;
notEmpty.signal();
}
当我们往队列里插入一个元素时,如果队列不可用,阻塞生产者主要通过 LockSupport.park(this); 来实现
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
继续进入源码,发现调用 setBlocker 先保存下将要阻塞的线程,然后调用 unsafe.park 阻塞当前线程。
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
unsafe.park(false, 0L);
setBlocker(t, null);
}
ConcurrentLinkedQueue 是单向链表结构的无界并发队列。元素操作按照 FIFO (first-in-first-out 先入先出) 的顺序。适合“单生产,多消费”的场景。内存一致性遵循对ConcurrentLinkedQueue的插入操作先行发生于(happen-before)访问或移除操作。
它采用了CAS 算法+volatile来实现满足happen-before的访问或移除操作
特性
在ConcurrentLinkedQueue head 并不一定代表真正的head,tail 不一定代表真正的tail,但是可以通过head 遍历到真正的head,通过tail可以遍历到真正的tail.
同阻塞队列一样,ConcurrentLinkedQueue 入队的元素不能是空,当删除元素时,需要先设置元素节点Node的item域为null
head的不变性和可变性:
不变性:
可变性:
tail的不变性和可变性
不变性:
可变性:
tail的item可能为null,也可能不为null
tail节点的next域可以指向自身
允许tail滞后head,也就是说调用succc()方法,从head不可达ta
具体的原理和源码分析可以看下面两篇参考文章:
https://cloud.tencent.com/developer/article/1803570
https://blog.csdn.net/u014634338/article/details/78825440
ConcurrentLinkedDeque 是双向链表结构的无界并发队列。与 ConcurrentLinkedQueue 的区别是该阻塞队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除)。适合“多生产,多消费”的场景。内存一致性遵循对 ConcurrentLinkedDeque 的插入操作先行发生于(happen-before)访问或移除操作
parties 是参与线程的个数,第二个构造方法有一个 Runnable 参数,这个参数表示最后一个到达线程要做的任务(回调方法)
public int await() throws InterruptedException, BrokenBarrierException
public int await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException
线程调用 await() 表示自己已经到达栅栏,BrokenBarrierException 表示栅栏已经被破坏,破坏的原因可能是其中一个线程 await() 时被中断或者超时
相比CountDownLatch,CyclicBarrier的本质区别是,CountDownLatch的参与线程线程在countDown()之后,可以继续执行自己的任务,而CyclicBarrier的参与线程,必须保持等待,直到栅栏打开后(参与线程await的数量达到目标),才会进行后续任务。
两者在使用中,调用await()的主体是不一样的,CountDownLatch.await()通常是在主(调用)线程调用的,而CyclicBarrier.await()是在参与的任务线程调用的,主线程不一定需要await(),这也是为什么CyclicBarrier提供了自动reset的功能。(其目的就是,不断的在调用线程,循环编排任务线程的启动和等待)
在没有LockSupport之前,线程的挂起和唤醒咱们都是通过Object的wait和notify/notifyAll方法实现
①LockSupport不需要在同步代码块里 。所以线程间也不需要维护一个共享的同步对象了,实现了线程间的解耦。
②unpark函数可以先于park调用,所以不需要担心线程间的执行的先后顺序。
public class TestObjWait {
public static void main(String[] args)throws Exception {
Thread A = new Thread(new Runnable() {
@Override
public void run() {
int sum = 0;
for(int i=0;i<10;i++){
sum+=i;
}
LockSupport.park();
System.out.println(sum);
}
});
A.start();
//睡眠一秒钟,保证线程A已经计算完成,阻塞在wait方法
Thread.sleep(1000);
LockSupport.unpark(A);
}
}
jdk中独占锁的实现除了使用关键字synchronized外,还可以使用ReentrantLock。虽然在性能上ReentrantLock和synchronized没有什么区别,但ReentrantLock相synchronized而言功能更加丰富,使用起来更为灵活,也更适合复杂的并发场景。
Lock lock = new ReentrantLock(true);
......
lock.tryLock(30000);
try {
// 对锁定对象进行更新等操作
//处理异常
} finally {
lock.unlock();
}
在synchronied的代码中,进入临界区的代码是无法中断的,这个很不灵活,如果我们使用一个线程池来分发任务,如果一个代码长期占有锁肯定会影响到线程池的其他任务,因此,加入中断机制提高了对任务更强的控制性。
public boolean sendOnSharedLine(String message) throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException { ... }
public boolean Polling(Account fromAcct, Account toAcct) {
while (true) {
if (fromAcct.lock.tryLock()) {
try {
if (toAcct.lock.tryLock()) {
try { //业务逻辑
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
}
return false;
}
condition可以用来替代Thread类的,wait 和 notify 方法,两者的区别是condition可以实现唤醒指定的线程
其底层运用的是Unsafe.park() 和 Unsafe.unpark()
public class BoundedBuffer {
final Lock lock = new ReentrantLock();//锁对象
final Condition notFull = lock.newCondition();//写线程条件
final Condition notEmpty = lock.newCondition();//读线程条件
final Object[] items = new Object[100];//缓存队列
int putptr/*写索引*/, takeptr/*读索引*/, count/*队列中存在的数据个数*/;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)//如果队列满了
notFull.await();//阻塞写线程
items[putptr] = x;//赋值
if (++putptr == items.length) putptr = 0;//如果写索引写到队列的最后一个位置了,那么置为0
++count;//个数++
notEmpty.signal();//唤醒读线程
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)//如果队列为空
notEmpty.await();//阻塞读线程
Object x = items[takeptr];//取值
if (++takeptr == items.length) takeptr = 0;//如果读索引读到队列的最后一个位置了,那么置为0
--count;//个数--
notFull.signal();//唤醒写线程
return x;
} finally {
lock.unlock();
}
}
}
public class Cache<K,V> {
final Map<K,V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
final Lock r = rwl.readLock(); //读锁
final Lock w = rwl.writeLock(); //写锁
/**
* 读缓存
* @param key
* @return
*/
V get(K key){
//获取读锁
r.lock();
try {
return m.get(key);
}finally {
r.unlock();
}
}
/**
* 写缓存
* @param key
* @param value
* @return
*/
V put(K key,V value){
w.lock();
try{
return m.put(key,value);
}finally {
w.unlock();
}
}
}
由于ReentrantLock的读写存在,在读多写少的场景下,会造成写饥饿的问题,jdk8引入了StampedLock
该类是一个读写锁的改进,它的思想是读写锁中读不仅不阻塞读,同时也不应该阻塞写:在读的时候如果发生了写,则应当重读而不是在读的时候直接阻塞写!
因为在读线程非常多而写线程比较少的情况下,写线程可能发生饥饿现象,也就是因为大量的读线程存在并且读线程都阻塞写线程,因此写线程可能几乎很少被调度成功!当读执行的时候另一个线程执行了写,则读线程发现数据不一致则执行重读即可。
所以读写都存在的情况下,使用StampedLock就可以实现一种无障碍操作,即读写之间不会阻塞对方,但是写和写之间还是阻塞的!
StampedLock锁提供了三种模式的读写控制:
是个排它锁或者叫独占锁,同时只有一个线程可以获取该锁,当一个线程获取该锁后,其它请求的线程必须等待,当目前没有线程持有读锁或者写锁的时候才可以获取到该锁,请求该锁成功后会返回一个stamp票据变量用来表示该锁的版本,当释放该锁时候需要unlockWrite并传递参数stamp。
是个共享锁,在没有线程获取独占写锁的情况下,同时多个线程可以获取该锁,如果已经有线程持有写锁,其他线程请求获取该读锁会被阻塞。这里说的悲观是说在具体操作数据前悲观的认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑,请求该锁成功后会返回一个stamp票据变量用来表示该锁的版本,当释放该锁时候需要unlockRead并传递参数stamp。
是相对于悲观锁来说的,在操作数据前并没有通过CAS设置锁的状态,如果当前没有线程持有写锁,则简单的返回一个非0的stamp版本信息,获取该stamp后在具体操作数据前还需要调用validate验证下该stamp是否已经不可用,也就是看当调用tryOptimisticRead返回stamp后到到当前时间间是否有其他线程持有了写锁,如果是那么validate会返回0,否者就可以使用该stamp版本的锁对数据进行操作。由于tryOptimisticRead并没有使用CAS设置锁状态所以不需要显示的释放该锁。该锁的一个特点是适用于读多写少的场景,因为获取读锁只是使用与或操作进行检验,不涉及CAS操作,所以效率会高很多,但是同时由于没有使用真正的锁,在保证数据一致性上需要拷贝一份要操作的变量到方法栈,并且在操作数据时候可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。
StampedLockd的内部实现是基于CLH锁的,CLH锁原理:锁维护着一个等待线程队列,所有申请锁且失败的线程都记录在队列。一个节点代表一个线程,保存着一个标记位locked,用以判断当前线程是否已经释放锁。当一个线程试图获取锁时,从队列尾节点作为前序节点, 使用类似如下代码(一个空的死循环)判断前序节点是否已经成功的释放了锁:
while(pred.locked){ }
pred表示当前试图获取锁的线程的前序节点,如果前序节点没有释放锁,则当前线程就执行该空循环并不断判断前序节点的锁释放,即类似一个自旋锁的效果,避免被系统挂起。当循环一定次数后,前序节点还没有释放锁,则当前线程就被挂起而不再自旋,因为空的死循环执行太多次比挂起更消耗资源。
参考资料:
https://duanguangguang.github.io/2018/12/31/concurrent/stampedlock/
在J2SE 1.5的java.util.concurrent包(下称j.u.c包)中,大部分的同步器(例如锁,屏障等等)都是基AbstractQueuedSynchronizer(下称AQS类)这个简单的框架来构建的。这个框架为同步状态的原子性管理、线程的阻塞和解除阻塞以及排队提供了一种通用机制
AQS中的int类型的state值,这里就是通过CAS(乐观锁)去修改state的值。lock的基本操作还是通过乐观锁来实现的。获取锁通过CAS,没有获取到锁,通过自旋CAS等待获取锁
关键方法:
参考文章:
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
AQS 结构
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
static final class Node {
volatile int waitStatus; // 节点状态
volatile Node prev; // 前置指针
volatile Node next; // 后继指针
volatile Thread thread; // 当前线程
}
private transient volatile Node head; // 队列头指针
private transient volatile Node tail; // 队列未指针
// =0:无锁状态
// >0:已有线程获取锁,state 数值表示重入次数
private volatile int state;
}
有一个异步编排任务,在Controller层的某个方法内,主调用线程会通过线程池,启动两个子任务线程,启动后,调用线程阻塞,等待任务线程都完成后,继续后续的逻辑。
线程池的核心参数如下图所示:
请问等待队列设置为10000,还是设置成0 后的吞吐量高
Fork/Join 框架是一种在 JDK 7 引入的线程池,用于并行执行把一个大任务拆成多个小任务并行执行,最终汇总每个小任务结果得到大任务结果的特殊任务。
通过其命名也很容易看出框架主要分为 Fork 和 Join 两个阶段,第一阶段 Fork 是把一个大任务拆分为多个子任务并行的执行,第二阶段 Join 是合并这些子任务的所有执行结果,最后得到大任务的结果。
不难发现其执行主要流程:首先判断一个任务是否足够小,如果任务足够小,则直接计算,否则,就拆分成几个更小的小任务分别计算,这个过程可以反复的拆分成一系列小任务。
Fork/Join 框架是一种基于分治的算法,通过拆分大任务成多个独立的小任务,然后并行执行这些小任务,最后合并小任务的结果得到大任务的最终结果,通过并行计算以提高效率。
通常情况下,我们并不需要直接继承这个 ForkJoinTask 类,而是使用框架提供的两个 ForkJoinTask 的子类:
public class SumTask extends RecursiveTask<Long> {
...
protected Long compute() {
if (任务足够小?) {
return computeDirect();
}
// 任务太大,一分为二:
SumTask subtask1 = new SumTask(...);
SumTask subtask2 = new SumTask(...);
// 分别对子任务调用fork():
invokeAll(subtask1, subtask2);
// 合并结果:
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
return subresult1 + subresult2;
}
}