并发编程的三个问题

可见性原子性有序性

缓存

CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。最靠近CPU的缓存称为L1,然后依次是L2,L3和主内存,CPU缓存模型如图下图所示:

CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。1. L1是最接近CPU的,它容量最小,例如32K,速度最快,每个核上都有一个L1 Cache。2. L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache。3. L3 Cache是三级缓存中最大的一级,例如12MB,同时也是缓存中最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。

Cache的出现是为了解决CPU直接访问内存效率低下问题的,程序在运行的过程中,CPU接收到指令后,它会最先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,如果命中缓存,CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人,当运算结束之后,再将CPUCache中的最新数据刷新到主内存当中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能力。但是由于一级缓存(L1 Cache)容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3Cache、内存(主存)和硬盘。

Java内存模型——JMM

内存模型概念

Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,主要实现是synchronized和volatile,具体如下:

主内存:主内存是所有线程都共享的,都能访问的,所有的共享变量都存储于主内存,例如静态成员变量等工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量

内存模型与CPU架构关系

主内存与工作内存间的交互

Java内存模型中定义了以下8种原子操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的,对应如下的流程图:

lock->read->load->use->assign->store->write->unlock

注意:

如果对一个变量执行lock操作,将会清空工作内存中此变量的值对一个变量执行unlock操作之前,必须先把此变量同步到主内存中

synchronized作用

能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。

synchronized的两种用法

对象锁:

方法锁(锁的是this)同步代码块

类锁:

修饰静态方法(锁的是class对象)指定锁为Class对象

如果不是static,则被synchronized修饰的方法,两个实例调用不是一把锁

Java对象的布局

synchronized控制粒度

粒度∶线程而非调用(用3种情况来说明和 pthread(调用)的区别),只要线程拿到了锁,访问其他的方法需要的是这同一把锁,就不需要再去获取这把锁

情况1:证明同一个方法是可重入的(synchronized方法调用本身)情况2:证明可重入不要求是同一个方法(synchronized方法调用另一个synchronized方法)情况3:证明可重入不要求是同一个类中的(synchronized方法调用其他类的synchronized方法)

synchronized的特性

可重入性质

什么是可重入(递归锁):指的是同一线程的外层函数获得锁之后,内层函数可以直接再次获取该锁。类似牌照摇号,可重入就是拿到之后就可以再次用起来,可以一直摇号,如果还需要重新竞争的话就叫不可重入锁

可重入有什么好处:

避免死锁:如果不可重入就会一直等待锁,很容易造成死锁提升封装性:同步代码块可以调用另一个同步代码块

synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁,在执行完同步代码块时,计数器的数量会-1,知道计数器的数量为0,就释放这个锁

不可中断性质

一旦这个锁已经被别人获得了,如果我还想获得,我只能选择等待或者阻塞,直到别的线程释放这个锁,如果别人永远不释放锁那么我只能永远地等下去。

相比之下,未来会介绍的Lock类,可以拥有被中断的能力,这有两点体现:

如果我觉得我等待的时间太长了不想再等了,可以退出阻塞——tryLock

synchronized功能实现

保证原子性

synchronized保证只有一个线程拿到锁,能够进入同步代码块。

保证可见性

通过JMM(Java内存模型)进行控制,执行synchronized时,会对应lock原子操作会刷新工作内存中共享变量的值,例如println就用到了synchronized修饰

保证有序性

我们加synchronized后,依然会发生重排序,只不过我们有同步代码块,可以保证只有一个线程执行同步代码中的代码,保证有序性

synchronized的工作原理

JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。

当多个线程同时访问一段同步代码时,多个线程会先被存放在EntryList集合(也可称为阻塞队列)中,处于BLOCKED状态的线程,都会被加入到该列表。

接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。

如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入WaitSet集合(也可称为等待队列)中,等待下一次被唤醒。此时线程会处于WAITING或者TIMEDWAITING状态,

如果当前线程顺利执行完方法,也将释放 Mutex。

总的来说,就是同步锁在这种实现方式中,因 Monitor 是依赖于底层的操作系统实现,存在用户态与内核态之间的切换(可以理解为上下文切换),所以增加了性能开销。

synchronized底层原理

monitor

synchronized的锁对象会关联一个monitor(c++层面的),它才是真正的锁,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块发现锁对象没有monitor就会创建monitor。monitor内部有两个重要的成员变量:

owner:拥有这把锁的线程recursions:会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待

monitorenter

监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权,其过程如下:

若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1,当前线程成为monitor的owner(所有者)若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权

monitorexit

能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。

执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权

monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit,因此synchroznied出现异常会释放锁

同步方法

可以看到同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰。会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit

深入monitor的JVM源码

monitor的结构

在HotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp),ObjectMonitor主要数据结构如下:

ObjectMonitor(){

_header=NULL;_count=0;_waiters=0;

_recursions=0;//线程的重入次数

_object=NULL;//存储该monitor的对象

_owner=NULL;//标识拥有该monitor的线程

_WaitSet=NULL;//处于wait状态的线程,会被加入到_WaitSet

_WaitSetLock=0;_Responsible=NULL;_succ=NULL;

_cxq=NULL;//多线程竞争锁时的单向列表

FreeNext=NULL;

_EntryList=NULL;//处于等待锁block状态的线程,会被加入到该列表

_SpinFreq=0;_SpinClock=0;OwnerIsThread=0;

}

_owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的_cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指向新值(新线程),因此_cxq是一个后进先出的stack(栈)_EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中_WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中

当owner释放锁之后,下一个抢占线程可能来自EntryList,也可能来自WaitSet

monitor竞争

通过CAS尝试把monitor的owner字段设置为当前线程如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行recursions ++ ,记录重入的次数如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获得锁并返回如果获取锁失败,则等待锁的释放。

monitor等待

当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock尝试获取锁。

monitor释放

退出同步代码块时会让_recursions减1,当_recursions的值减为0时,说明线程释放了锁根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成被唤醒的线程,会回到voidATTRObjectMonitor::EnterI(TRAPS)的第600行,继续执行monitor的竞争

monitor是重量级锁

可以看到ObjectMonitor的函数调用中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒

这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized是Java语言中是一个重量级(Heavyweight)的操作

JDK 6的synchronized优化

为什么要进行锁升级

在Java早期版本中,synchronized属于重量级锁,效率低下,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高

为了提升性能,JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的Java对象头实现了锁升级功能。

synchronized锁升级过程

锁升级过程:无锁–>偏向锁–>轻量级锁–>重量级锁,synchronized同步锁初始为偏向锁,随着线程竞争越来越激烈,偏向锁自旋升级到轻量级锁,最终升级到重量级锁

synchronized优化——偏向锁

升级场景

hotspot虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,我们一开始加锁上的是偏向锁,当一个线程访问加了同步锁的代码块时,首先会尝试通过CAS操作在对象头中存储当前线程的ID

如果成功markword则存储当前线程ID,接着执行同步代码块如果是同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,可直接执行同步代码块如果有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行

升级优点

偏向锁主要用来优化同一线程多次申请同一个锁的竞争,也就是现在的Synchronized锁实际已经拥有了可重入锁的功能。

为什么要有偏向锁

因为在我们的应用中,可能大部分时间是同一个线程竞争锁资源(比如单线程操作一个线程安全的容器),如果这个线程每次都要获取锁和释放锁,那么就在不断的从内核态与用户态之间切换。

那么有了偏向锁,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头中去判断一下是否当前线程是否持有该偏向锁就可以了。

一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点(JVM的stop the world),暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。

synchronized优化——轻量级锁

升级场景

当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换对象头中的线程 ID 为自己的 ID,该锁会保持偏向锁状态,如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。

升级过程

撤销偏向锁,升级轻量级锁,每个线程在自己的线程栈生成LockRecord,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁。轻量级锁在加锁过程中,用到了自旋锁,自旋锁的使用,其实也是有一定条件的,如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源,满足以下两种情况之一后升级为重量级锁

默认情况下自旋的次数是 10 次,可以通过-XX:PreBlockSpin来修改,或者自旋线程数超过CPU核数的一半在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源

升级优点

轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争

轻量级锁也支持自旋,因此其他线程再次争抢时,如果CAS失败,将不再会进入阻塞状态,而是不断自旋。之所以自旋更好,是因为之前说了,默认线程持有锁的时间都不会太长,如果线程被挂起阻塞可能代价会更高

如果自旋锁重试之后抢锁依然失败,那么同步锁就会升级至重量级锁

synchronized优化——重量级锁

在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在WaitSet集合中,也就变成了优化之前的Synchronized锁。

java中每个对象都关联了一个监视器锁monitor,当monitor被占用时就会处于锁定状态。线程执行monitorenter 指令时尝试获取monitor的所有权,过程如下:

如果monitor的进入数为 0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor 的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加 1。如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为 0,再重新尝试获取monitor的所有权。

从上面过程可以看出两点,第一:monitor是可重入的,他有计数器,第二:monitor是非公平锁

synchronized优化——锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。下面这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。

StringBuffer的append ( ) 是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

synchronized优化——锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

例如StringBuffer的append有synchronized,循环100次的话JVM可能会把这个方法的synchronized抹掉,反而是在整个for循环加上synchronized

代码使用synchronized的原则

减少synchronized的范围

降低synchronized锁的粒度:将一个锁拆分为多个锁提高并发度,例如ConcurrentHashMap

读写分离:读取时不加锁,写入和删除时加锁,例如:ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet

多线程访问同步方法的7种情况

两个线程同时访问一个对象(即runnable接口是一个实例)的同步方法(起作用)

两个线程访问的是两个对象(即runnable接口不是一个实例)的同步方法(不起作用)

两个线程访问的是一个对象/两个对象synchronized的静态方法(起作用)

两个线程同时访问同步方法与非同步方法(非同步方法的访问不被影响)

两个线程同时访问同一个对象的不同的普通同步方法(两个方法的访问无法同时进行,因为是一把锁)

同时访问静态synchronized和非静态 synchronized方法(两个方法的访问可以同时进行,非静态锁的是this,静态锁的是class对象)

方法拋异常后,JVM会帮我们释放锁,Lock抛出异常后不会释放锁,需要显式释放

复盘:三个核心思想

把锁只能同时被一个线程获取,没有拿到锁的线程必须等待(对应第1、5种情况)每个实例都对应有自己的一把锁,不同实例之间互不影响。例外锁对象是* class以及 synchronized 修饰的是 static方法的时候,所有对象共用同一把类锁(对应第2、3、4、6种情况)无论是方法正常执行完毕或者方法抛岀异常,都会释放锁(对应第7种情况)

常见面试题

synchronized与Lock的区别

synchronized是关键字,而Lock是一个接口synchronized会自动释放锁,而Lock必须手动释放锁synchronized是不可中断的,Lock可以中断也可以不中断(等待过程)通过Lock可以知道线程有没有拿到锁,而synchronized不能synchronized能锁住方法和代码块,而Lock只能锁住代码块Lock可以使用读锁提高多线程读效率synchronized是非公平锁,ReentrantLock可以控制是否是公平锁

多线程访问同步方法的各种具体情况

就是多线程访问同步方法的7种情况

课外知识补充

Linux系统的体系架构

Linux操作系统的体系架构分为:用户空间(应用程序的活动空间)和内核

内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。

用户态与内核态转化

所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态)。但是当它调用系统调用执行某些操作时,例如 I/O调用,此时需要陷入内核中运行,我们就称进程处于内核运行态(或简称为内核态)。

系统调用的过程可以简单理解为:

用户态程序将一些数据值放在寄存器中,或者使用参数创建一个堆栈,以此表明需要操作系统提供的服务用户态程序执行系统调用CPU切换到内核态,并跳到位于内存指定位置的指令系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果

由此可见用户态切换至内核态需要传递许多变量,同时内核还需要保护好用户态在切换时的一些寄存器值、变量等,以备内核态切换回用户态。这种切换就带来了大量的系统资源消耗,这就是在synchronized未优化之前,效率低的原因。

Java对象的布局

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充,如下图所示:

对象头

在普通实例对象中,oopDesc的定义包含两个成员,分别是_mark和_metadata

_mark表示对象标记、属于markOop类型,也就是接下来要讲解的Mark World,它记录了对象和锁有关的信息_metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、_compressed_klass表示压缩类指针

对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。Mark Word对应的类型是markOop,源码位于markOop.hpp中。

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

在32位虚拟机下,Mark Word是32bit大小的,其存储结构如下:

klass pointer

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。

为了节约内存可以使用选项-XX:+UseCompressedOops开启指针压缩,其中,oop即ordinaryobject pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

每个Class的属性指针(即静态变量)每个对象的属性指针(即对象变量)普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。

对象头 = Mark Word + 类型指针(未开启指针压缩的情况下)

在32位系统中,Mark Word = 4 bytes,类型指针 = 4bytes,对象头 = 8 bytes = 64 bits在64位系统中,Mark Word = 8 bytes,类型指针 = 8bytes,对象头 = 16 bytes = 128bits

实例数据

就是类中定义的成员变量。

对齐填充

对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍

换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

一些思考题

多个线程等待同一个synchronized锁的时候,JVM如何选择下个获取锁的是哪个线程?

目前是不公平的状态,和JVM有关

Synchronized使得同时只有一个线程可以执行,性能较差,有什么办法可以提升性能?

优化使用范围

使用其他类型Lock

我想更灵活地控制锁的获取和释放(现在释放锁的时机都被规定死了),怎么办?

可以自己实现一个锁

什么是锁的升级、降级?什么是JVM里的偏向锁、轻量级锁、重量级锁?

以前的synchronized锁性能不高,后来出了偏向锁、轻量级锁、重量级锁等东西提高了它的性能,JVM会根据使用的种种指标来对锁进行优化,还涉及对象头的一些字段