原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一单开始,就不会被其他线程干扰。
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
程序在执行时,编译器可能会进行指令重排,重排后的指令原指令的顺序未必一致。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。
为什么要进行指令重排?
CPU执行指令,需要进行这几步(不同的指令集可能不同,一般认知中是这样的)
Happen-Before原则是不进行指令重排的规则:
因为在CPU执行指令的时候,会进行指令重排,指令重排在串行上可以保证程序语义一致,但是在多线程情况下,就无法保证语义一致了。
举个例子:
一个全局变量,每个线程对全局变量进行1w次自增操作。如果有10个线程,那么最终的全局变量的值应该是10W。
串行:
public class Main {
private static Long sum = 0L;
public static void main(String[] args) {
System.out.println("main start sum = " + sum);
ExecutorService service = new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
new Add().run();
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + sum);
}
static class Add implements Runnable {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start add!");
for (int i = 0; i < 10000; i++) {
sum++;
}
System.out.println(thread.getName() + thread.getId() + " add over!");
}
}
}
执行结果:
并发:
每次执行的结果都是不确定的。
所以,在并发情况下,对同一个变量的操作,会出现语义不一致的并发问题。
那么,如何解决这个问题呢?
加锁。
一般来说,Java中锁的实现有两种方式:synchronized和Lock.
我们先用synchronized修改
接下来使用Lock进行修改:
public class Main {
private static volatile Long sum = 0L;
private static volatile Lock lock = new ReentrantLock();
public static void main(String[] args) {
System.out.println("main start sum = " + sum);
ExecutorService service = new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
service.execute(new Add());
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + sum);
}
static class Add implements Runnable {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start add!");
try {
while(!lock.tryLock()){
TimeUnit.MILLISECONDS.sleep(200);
}
for (int i = 0; i < 10000; i++) {
sum++;
}
}catch (InterruptedException e){
System.out.println(e);
} finally {
lock.unlock();
}
System.out.println(thread.getName() + thread.getId() + " add over!");
}
}
}
我们增加了一个全局变量,这个全局变量就是sum的锁,只有获取到了锁的线程,才能进行累加操作。如果没有获取锁,那么就线程sleep200毫秒。然后重新获取锁,直到获取了锁,否则就一直循环。
在2.1 中,我们可以很明显的看到,这是因为并发情况下,多个线程对全局变量的读写,造成语义不一致。
说简单点,就是第一个线程和第二个线程等多个线程读取到了相同的sum初始值,然后对sum初始值进行递增操作,导致多个线程递增和一个线程的一次递增的结果相同。(不考虑时间先后问题)(线程间指令重排问题)
还有就是第一个线程可能计算的快,已经计算到了 9++=10了,但是第二个线程还是比较慢,才计算到了1++=2。(线程执行,多个核心执行,每个核心的寄存器里面都有sum的一个副本)(内存可见性问题)
锁的存在就是为了解决这些问题。
分类 | 具体场景 | 被锁的对象 | 伪代码 |
---|---|---|---|
方法 | 实例方法 | 类的实例对象 | public synchronized void method(){} |
方法 | 静态方法 | 类对象 | public static synchronized void method(){} |
代码块 | 实例对象 | 类的实例对象 | synchronized (this){} |
代码块 | class对象 | 类对象 | synchronized(Main.class){} |
代码块 | 任意实例对象Object | 实例对象Object | String x = “”; synchronized(x){} |
首先我们将2.1中的synchronized实现的代码进行编译javac Main.java
,然后使用javap -v
进行反编译
这里比较好找,先找递增操作,ladd的指令,在ladd的指令前后有monitorenter指令。
来源:https://blog.csdn.net/z_ssyy/article/details/103737553
通过上面两张图片,可以很直观的知道,对象在jvm中分为三块区域:对象头,对象实际数据,填充数据。
来自:https://blog.csdn.net/javazejian/article/details/72828483
monitor指令分为两个:monitorenter和monitorexit。
分别代码开始同步和结束同步。或者开始加锁,结束加锁。
可以理解为:在遇到monitorenter指令的时候,进行加锁,进入同步代码后,每次进行操作前后,都需要获取最新的数据,执行完毕,及时的写回。(这是个人理解)
在执行过程中,遇到monitorenter指令,设置对象的锁标志以及线程id(重入锁的核心实现)。
因为第一个争夺到锁的线程已经将锁标志置1了,其他线程就无法获取锁了(无法在增加了)。
当执行完同步操作后,遇到monitorexit指令,设置对象的锁标志为0,线程id清空(网上的资料没有指明不过从重入锁的定义来分析,应该是清空id的)
这样其他线程就可以获取锁了。
在2.3.2.2小节中知道,每一个对象都有自己的对象头,而在对象头中有一个锁标志,只有线程修改锁标志成功,才是获取到了锁,其他线程只能等待。
所以,如果有若干线程同时获取一个对象的锁,其中某一个线程得到锁之后,执行线程的任务,而其他锁则会进入同步队列,线程也会进入BLOCKED的状态。
图片来自https://www.jianshu.com/p/d53bf830fa09
首先理解一个关键字static,这个关键字是区分一个属性变量是否是类变量,还是实例属性变量。
同样的,一个方法如果有static就是说,这个这个方法是类方法;如果以一个方法没有static 就认为这个方法是实例方法。
当然,最明显的是:类方法和类变量,可以直接通过类名调用;而实例方法和实例变量,必须先创建类的实例,然后通过实例调用。
还有一点需要注意:非static方法可以调用static方法和非static方法,而static方法只能调用static方法。
从对象的角度来看:
类对象,类方法不需要使用new实例化对象,就可以调用类方法和类变量。
因为类对象在内存中只会存储一个。
还记得前面说的JVM中对象的结构吗,在对象头中,就会存储类元数据:
来自:https://blog.csdn.net/z_ssyy/article/details/103737553
实例对象的对象头中存储的这个类元数据就是类对象的地址。
也就是说,在内存中,这个类的所有实例对象的对象头都会存储类元信息,也就是类对象。而且这些实例对象的类对象都是相同的。
用最直白的话说:类对象,内存中只有一份;实例对象,每new一次,就会有一个。
因为内存中只有一个,所以不管是类变量还是类属性,,都是同一个,怎么调用都行。
而实例方法或者实例对象调用类方法或者类属性:因为实例对象和类对象是多对1的关系,所以实例方法调用类方法或者类属性就是互斥的。在同一时刻,只能有一个实例对象可以调用成功(有锁,或者有同步逻辑的)。如果是不需要同步的,那无所谓了。
比如:
public class Student {
public static void say() {
System.out.println("static method");
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId());
while (!thread.isInterrupted()) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
break;
}
}
System.out.println("static end");
}
public void sing() {
System.out.println("nomal method");
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId());
while (!thread.isInterrupted()) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
break;
}
}
System.out.println("nomal method end");
}
}
public class StudentMain {
public static void main(String[] args) {
Student student = new Student();
Thread t1 = new Thread(() -> {
Student.say();
});
t1.start();
Thread t2 = new Thread(() -> {
student.sing();
});
t2.start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main method , static method and nomal method is BLOCKED");
new Thread(() -> Student.say()).start();
new Thread(() -> student.sing()).start();
new Thread(() -> new Student().sing()).start();
}
}
执行结果:
即使第一次调用的线程现在在方法内阻塞,但是,因为方法不是同步方法,所以,后面创建的线程依然可以访问,依然可以进入。
那么,把方法修改成需要同步的呢?
这个时候,实例同步方法可以进入,但是类同步方法不可以进入。
从这里也进一步说明,类对象,类方法在内存中是一份的。而实例方法是每new一次,就会产生一个的。
然后实例对象的类元信息就是类对象的地址。
这个时候,我们返回去看下2.3.1的使用场景
其实就是可以分为2类,一种是实例对象锁,一种是类对象锁。
接下来,在看一个例子:
在多线程的情况下,多个线程对同一个属性进行操作,会发生并发问题。
我们通过实例查看:
public class People {
private Long sum = 0L;
private static Long all = 0L;
public People(){}
public Long getSum(){
return sum;
}
public void setSum(Long sum){
this.sum = sum;
}
public Long getAll(){
return all;
}
public void setAll(Long all){
People.all = all;
}
}
public class Main {
public static void main(String[] args) {
People people = new People();
Runnable runnable = () -> {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start ");
for (int i = 0; i < 10000; i++) {
people.setAll(people.getAll() + 1);
people.setSum(people.getSum() + 1);
}
System.out.println(thread.getName() + thread.getId() + " end ");
};
System.out.println("main thread sum = " + people.getSum() + " , all = " + people.getAll());
ExecutorService service = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
service.execute(runnable);
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + people.getSum() + " , all = " + people.getAll());
}
}
我们创建了一个类,类里面有两个属性,一个是static的,另一个是非static的。
也就是说,all是类变量,sum是实例变量。
在主线程中,我们一个线程将People的属性值增加1W,那么,10个线程就是10W。
我们预期的目标是all和sum都是10W。
为了解决这个问题,有两种解决方式:加锁(Lock)或者同步(synchronized)
在这里只考虑同步的实现方式。
你可能注意到了,我们的People中的两个属性,一个是类属性,一个是实例属性。
首先,我们使用代码块同步类的方式,进行同步:
运行结果:
预期分析:因为使用的是类同步,对于每一个实例对象来说,对应的都是同一个类对象。所以当这10个线程的其中某一个线程获取了类同步的锁,其他线程就无法获取类同步的锁了,其他线程就会被阻塞了。
这样就保证了同一时间只会有一个线程操作类变量和实例变量。就不存在并发问题了。
接下来,我们使用对象同步呢?
预期分析:经过上面的例子,这个可以很轻松的分析出来,这个例子也能达到我们的目的。
因为这10个线程使用的是同一个实例对象,所以使用实例对象,也就是10个线程在竞争一个实例对象的同步锁。
也能够保证同一时间内,只有一个线程操作类变量和实例变量。
上面两个小例子是synchronized同步对象的例子,在同步代码块的场景中,还有一种,同步任意实例对象。
其实同步任意实例对象和同步某一个实例对象的原理是一样的:
在这种写法下,10个线程竞争同一个实例对象的同步锁,当然可以保证同一时间内只有一个线程进行操作。
可是,如果每一个线程使用的都是自己线程内创建的实例对象呢?
预期分析:因为我们将实例对象放到了线程内,那么首先这个10个线程对应的是10个实例对象,每一个线程同步的都是自己线程内创建的对象,这当然每一个线程都能够获取到实例对象锁了,也就是每一个线程在任意时间都可以操作类变量和实例变量。
也就无法达到预期目标了。
换个角度想,当我们将实例对象的创建移到线程内的时候,对于每一个单个的线程来说,其同步的都是自己线程内的局部变量。
我们看完了synchronized同步代码块,接下来看看synchronized同步方法:
预期分析:
因为方法是类方法,在整个内存中只有一个,所以,可以保证同一时间只有一个线程能够获取锁。
这个可能不太好对比:
我们新增了两个方法,一个是类方法,一个是实例方法。
因为我们在类方法上进行同步,所以类变量符合预期结果,而实例方法因为没有进行同步,所以,实例变量不符合预期结果:
接下来我们根据上面的例子,同步实例方法,然后不同步类方法,以作对比:
预期分析:因为类方法没有进行同步,所以类方法应该不符合预期结果。
而实例方法进行同步,那么同步方法应该是符合预期的。
即使这样调用,类方法也不同步的:
在jdk5之后,jvm对synchronized做了优化。
在jdk5之后,jvm对synchronized进行了优化。
在jdk5之后,线程使用synchronized进行同步,首先会使用偏向锁,如果有第二个线程竞争锁,此时锁会升级为轻量级锁,多个线程竞争轻量级锁,未竞争到锁的线程进行自旋等待。如果自旋超过10次还未获取到锁,那么锁就会升级为重量级锁。
偏向锁的机制也比较简单,在对象的对象头中写入了一个线程的id,那么此时,如果这个线程再次获取锁,jvm将对象的对象头中的线程id与竞争锁的线程id进行对比,如果是一样的,那么这个线程就直接获取锁。
如果有多于1个线程进行竞争锁,此时偏向锁只能记录一个线程id,就不合适了,此时会升级为轻量级锁。
偏向锁的设计思想是:在大多数程序中,我们还是串行处理占多数;并发处理的时间或者操作占比比较低。
轻量级锁的设计思想是:在大多数并发中,我们需要同步加锁的操作是比较简单,快速的操作,占整个线程处理时间的占比很小。所以,每一个线程获取到锁之后,大多数是在很短的时间内就会释放。
重量级锁的设计思想是:即使并发冲突的概率比较小,但是并发冲突的造成的后果非常的严重。当并发冲突无法避免的时候,我们就需要保证并发的安全。
当有多个线程一起访问某个对象的monitor对象的时候,对象监视器会将这些线程存储在不同的容器中:
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
- Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
- Owner:当前已经获取到所资源的线程被称为Owner;
- !Owner:当前释放锁的线程。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
来自:https://blog.csdn.net/zqz_zqz/article/details/70233767