Java 并发 API 包括多种同步机制,可以支持你:
定义用于访问某一共享资源的临界段;
在某一共同点上同步不同
synchronized
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
同步语句块原理:利用2个指令对monitor加锁/释放锁实现同步
1、monitor enter 0可以
每个对象多有与monitor与之关联,一个monitor的lock锁只能被一个线程在同一时间获取,在一个线程尝试获取与锁对象相关联的monitor时会发生以下几件事情。
如果monitor的计数器为0,意味着该monitor的lock锁还没有被获取,当一个线程获的后会立刻对该计数器+1,这样就代表这该monitor被占有
如果一个已经拥有该monitor所有权的线程重入,则会导致monitor的计数器再次被累加
如果monitor已经被其他线程占有,其他线程尝试获取该monitor的所有权时,被陷入到阻塞状态,知道monitor计数器变为0,才再次尝试获取monitor所有权
2、monitor exit
释放对monitor的所有权,前提是曾经获得过所有权。释放的过程较为简单,就是将monitor的计数器-1,如果计数器的结果为0。则代表这线程失去了对该monitor的所有权,与此同时被该monitor block的线程将再次尝试获取该monitor的所有权。
同步方法原理
1.同步⼀个代码块
它只作⽤于同⼀个对象,如果调⽤两个对象上的同步代码块,就不会进⾏同步。
对于以下代码,使⽤ ExecutorService 执⾏了两个线程,由于调⽤的是同⼀个对象的同步代码块,因此 这两个线程会进⾏同步,当⼀个线程进⼊同步语句块时,另⼀个线程就必须等待。
public class SynchronizedExample { public void func1() { synchronized (this) { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } } } public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func1()); executorService.execute(() -> e1.func1()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
对于以下代码,两个线程调⽤了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可 以看出,两个线程交叉执⾏。
public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); SynchronizedExample e2 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func1()); executorService.execute(() -> e2.func1()); }
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9
2.同步⼀个⽅法
它和同步代码块⼀样,作⽤于同⼀个对象。
3. 同步⼀个类
作⽤于整个类,也就是说两个线程调⽤同⼀个类的不同对象上的这种同步语句,也会进⾏同步
public class SynchronizedExample { public void func2() { synchronized (SynchronizedExample.class) { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } } }
public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); SynchronizedExample e2 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func2()); executorService.execute(() -> e2.func2()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁。synchronized
关键字加到实例方法上是给对象实例上锁。- 尽量不要使用
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能!
构造方法不能使用 synchronized 关键字修饰。
构造方法本身就属于线程安全的,不存在同步的构造方法一说
DK1.6 之后的 synchronized 关键字底层做了哪些优化
DK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率
Lock 接口:
Lock 提供了比 synchronized 关键字更为灵活的同步操作。Lock 接口有多种 不同类型:ReentrantLock 用于实现一个可与某种条件相关联的锁;ReentrantReadWriteLock 将读写操作分离开来;StampedLock 是 Java 8 中增加的一种新特性,它包括三种 控制读/写访问的模式
ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。
public class LockExample { private Lock lock = new ReentrantLock(); public void func() { lock.lock(); try { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } finally { lock.unlock(); // 确保释放锁,从⽽避免发⽣死锁。 } } } public static void main(String[] args) { LockExample lockExample = new LockExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> lockExample.func()); executorService.execute(() -> lockExample.func()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
⽐较
相同
两者都是可重入锁
可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,
如果是不可重入锁的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁
不同
1. 锁的实现
synchronized 是 JVM 实现的,⽽ ReentrantLock 是 JDK 实现的。
2 ReentrantLock 比 synchronized 增加了一些高级功能
2.1等待可中断
当持有锁的线程⻓期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
2.2 公平锁
公平锁是指多个线程在等待同⼀个锁时,必须按照申请锁的时间顺序来依次获得锁。 synchronized 中的锁是⾮公平的,ReentrantLock 默认情况下也是⾮公平的,但是也可以是公平的。
2.3 可实现选择性通知(锁可以绑定多个条件)
: synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
使⽤选择
除⾮需要使⽤ ReentrantLock 的⾼级功能,否则优先使⽤ synchronized。
这是因为 synchronized 是 JVM 实现的⼀种锁机制,JVM 原⽣地⽀持它,⽽ ReentrantLock 不是所有的 JDK 版本都⽀持。
并且使 ⽤ synchronized 不⽤担⼼没有释放锁⽽导致死锁问题,因为 JVM 会确保锁的释放
Semaphore 类:
该类通过实现经典的信号量机制来实现同步。可以控制对互斥资源的访问线程数。Java 支持二进制信号量和一般 信号量。
信号量机制是 Edsger Dijkstra 于 1962 年提出的,用于控制对一个或多个共享资源的访问。
该机制 基于一个内部计数器以及两个名为 wait()和 signal()的方法。
当一个线程调用了 wait()方法时, 如果内部计数器的值大于 0,那么信号量对内部计数器(可允许的)做递减操作,并且该线程获得对该共享资源的访问。
如果内部计数器的值为 0,那么线程将被阻塞,直到某个线程调用 singal()方法为止
当一 个线程调用了 signal()方法时,信号量将会检查是否有某些线程处于等待状态(它们已经调用了 wait()方法)。
如果没有线程等待,它将对内部计数器做递增操作。如果有线程在等待信号量,就获 取这其中的一个线程,该线程的 wait()方法结束返回并且访问共享资源。
其他线程将继续等待,直 到轮到自己为止。
CountDownLatch 类
CountDownLatch的作用就是等待其他的线程都执行完任务,必要时可以对各个任务的执行结果进行汇总,然后主线程才继续往下执行。
可用于使用多线程读取多个文件处理的场景
public class CountdownLatchExample { public static void main(String[] args) throws InterruptedException { final int totalThread = 10; CountDownLatch countDownLatch = new CountDownLatch(totalThread); ExecutorService executorService = Executors.newCachedThreadPool(); for (int i = 0; i < totalThread; i++) { executorService.execute(() -> { System.out.print("run.."); countDownLatch.countDown(); }); } countDownLatch.await(); System.out.println("end"); executorService.shutdown(); } }
CyclicBarrier
⽤来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执⾏。
和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执⾏ await() ⽅法之后计数器会减 1, 并进⾏等待,直到计数器为 0,所有调⽤ await() ⽅法⽽在等待的线程才能继续执⾏。
CyclicBarrier 和 CountdownLatch 的⼀个区别是,CyclicBarrier 的计数器通过调⽤ reset() ⽅法可以循 环使⽤,所以它才叫做循环屏障。
CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏 障的时候会执⾏⼀次。
简述java中volatile关键字作用
- 保证变量对所有线程的可见性。 当一条线程修改了变量值,新值对于其他线程来说是立即可以得知的。 如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据会立即写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
- 禁止指令重排序优化。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
synchronized 关键字和 volatile 关键字的区别
synchronized: 具有原子性,有序性和可见性;
volatile:具有有序性和可见性
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性
ThreadLocal
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
JDK 中提供的ThreadLocal
类正是为了解决这样的问题。 ThreadLocal
类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
原理
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题
每个Thread
中都具备一个ThreadLocalMap
,(Hashmap性质的),最终的变量是放在了当前线程的 ThreadLocalMap
中。而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。在私有ThreadLocalMap的操作。
ThreadLocal 内存泄露
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
JAVA中的乐观锁与CAS算法
对于乐观锁,开发者认为数据发送时发生并发冲突的概率不大,所以读操作前不上锁。
到了写操作时才会进行判断,数据在此期间是否被其他线程修改。如果发生修改,那就返回写入失败;如果没有被修改,那就执行修改操作,返回修改成功。
乐观锁一般都采用 Compare And Swap(CAS)算法进行实现。顾名思义,该算法涉及到了两个操作,比较(Compare)和交换(Swap)。
CAS 算法的思路如下:
一个线程失败或挂起并不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。而CAS就是一种非阻塞算法实现,也是一种乐观锁技术,
它能在不使用锁的情况下实现多线程安全,所以CAS也是一种无锁算法。
CAS比较并交换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。
CAS具体包括三个参数:当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。
CAS缺点
【1】循环时间长、开销很大。
当某一方法比如:getAndAddInt执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。
【2】只能保证一个共享变量的原子操作。
当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。
【3】存在ABA问题
ABA问题:
ABA问题是CAS中的一个漏洞。CAS的定义,当且仅当内存值V等于就得预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。
那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。
如何解决这个异常现象?java并发包为了解决这个漏洞,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”
简述阻塞队列
阻塞队列是生产者消费者的实现具体组件之一。当阻塞队列为空时,从队列中获取元素的操作将会被阻塞,当阻塞队列满了,往队列添加元素的操作将会被阻塞。具体实现有:
- ArrayBlockingQueue:底层是由数组组成的有界阻塞队列。
- LinkedBlockingQueue:底层是由链表组成的有界阻塞队列。
- PriorityBlockingQueue:阻塞优先队列。
- DelayQueue:创建元素时可以指定多久才能从队列中获取当前元素
- SynchronousQueue:不存储元素的阻塞队列,每一个存储必须等待一个取出操作
- LinkedTransferQueue:与LinkedBlockingQueue相比多一个transfer方法,即如果当前有消费者正等待接收元素,可以把生产者传入的元素立刻传输给消费者。
- LinkedBlockingDeque:双向阻塞队列
锁的四种状态与锁升级过程
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁
并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。
这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
synchronized
最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,synchronized
实现同步最初的方式,这也是当初开发者诟病的地方,这也是在JDK6以前 synchronized
效率低下的原因,JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。互斥锁与自旋锁
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程; 开销成本是什么呢?会有两次线程上下文切换的成本
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
自旋锁是通过 CPU 提供的 CAS
函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些
AQS
AQS(AbstractQuenedSynchronizer)抽象的队列式同步器。 AQS是将每一条请求共享资源的线程封装成一个锁队列的一个结点(Node),来实现锁的分配。
AQS是用来构建锁或其他同步组件的基础框架,它使用一个 volatile int state 变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。
子类通过继承同步器并实现它的抽象方法getState、setState 和 compareAndSetState对同步状态进行更改
AQS 定义两种资源共享方式
- Exclusive(独占):只有一个线程能执行,如
ReentrantLock
。又可分为公平锁和非公平锁:- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如
CountDownLatch
、Semaphore
、CyclicBarrier
、ReadWriteLock
我们都会在后面讲到。