文章目录
前言
这一系列资料基于黑马的视频:java并发编程,目前还没有看完,整体下来这是我看过的最好的并发编程的视频。下面是根据视频做的笔记。
1. 变量的线程安全分析
1. 成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
1. 如果只有读操作,则线程安全
2. 如果有读写操作,则这段代码是临界区,需要考虑线程安全
2. 局部变量是否线程安全?
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
1. 如果该对象没有逃离方法的作用访问,它是线程安全的
2. 如果该对象逃离方法的作用范围,需要考虑线程安全
3. 局部变量线程安全分析
方法中定义的变量叫局部变量,是不共享的。每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
public static void test1() {
int i = 10;
i++;
}
成员变量引用的问题:下面的例子:数组下标越界问题(private方法)
public class Test2 {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadLine unsafe = new ThreadLine();
Thread thread = new Thread();
for(int i = 0; i < THREAD_NUMBER; i++){
new Thread(()->{
unsafe.method1(LOOP_NUMBER);
}, "thread").start();
}
}
}
class ThreadLine{
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber){
for(int i = 0; i < loopNumber; i++){
//临界区,产生静态条件
method2();
method3();
}
}
private void method2(){
list.add("1");
}
private void method3(){
list.remove(0);
}
}
产生了竞态条件,下面分析两种错误的情况:
- 情况1:
- size和add的数量不匹配。流程是:加入线程1首先进入list.add(“1”),把1加入0的位置,此时刚准备要加入的时候CPU时间片用完了,也就是说list数组里面还是没有东西;
- 线程2分配到了CPU时间片,这时候线程而也执行list.add(“1”), 注意此时的线程2还是加入到index=0的位置,因为线程1还没有加进入。此时线程2把size设置为了1.
- 线程1唤醒了,又分配到了CPU时间片,开始执行add,但是注意,由于CPU是在刚准备加入的时候停止的,此时线程1要加入的位置还是index = 0,那么线程1执行add操作加入到index = 0的位置,然后把size设置为2.
- 这时候就有问题了,size = 2,实际上list里面才只有1个数据。
- 情况2
- 在情况1的基础上,size = 2,此时list里面只有一个数据
- 此时两个线程都运行了remove方法,那么会出现问题了
- 只有一个数据,却remove了两次,就会报错
Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
at java.util.ArrayList.rangeCheck(ArrayList.java:657)
at java.util.ArrayList.remove(ArrayList.java:496)
at cn.itcast.n6.ThreadUnsafe.method3(TestThreadSafe.java:35)
at cn.itcast.n6.ThreadUnsafe.method1(TestThreadSafe.java:26)
at cn.itcast.n6.TestThreadSafe.lambda$main$0(TestThreadSafe.java:14)
at java.lang.Thread.run(Thread.java:748)
方法改进:使用局部变量
class ThreadSafe{
public void method1(int loopNumber){
ArrayList<String> list = new ArrayList<>();
for(int i = 0; i < loopNumber; i++){
method2(list);
method3(list);
}
}
private void method2(ArrayList list){
list.add("1");
}
private void method3(ArrayList list){
list.remove(0);
}
}
分析:
- 这时候就时线程安全的了
- 因为是成员变量,所以每个线程都有自己的一块内存空间,list存在于自己独立的那块内存空间中,所以上面的情况不会出现,因为无论怎么修改,都是对自己独立的list进行修改,而不是对公有的list进行修改
- 从这个方面也不难发现,线程安全问题总是出现在对公有的成员变量的引用之中
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?
- 情况1:有其它线程调用 method2 和 method3,线程不安全
- 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,由于此时子类继承了ThreadSafe,导致list也被共享了(作为参数传到了子类里面可以任意调用)
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
2. 线程常见类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
- 它们的每个方法是原子的
- 但注意他们的多个方法组合不是原子的
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
1. 线程安全类方法的组合
- 分析下面代码是否线程安全?
答:不安全,因为线程1和线程2有机会同时进入if条件中,此时就有一个线程的值会被另一个线程put的值覆盖(和单例模式很像)
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
2. 不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。但是String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
我们从源码的角度分析:其实不难发现在最后一行出现了new String这个方法,也就是说,这里其实是通过new了一个新的String对象,才不会导致了线程安全的问题,因为操作的都不是同一个对象,也就没有线程不安全这一说法了。
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
3. 是否线程安全的几个例子
1、普通的成员变量
我们去查看一个变量是不是安全的最重要方法是看这个变量会不会被共享修改:
public class MyServlet extends HttpServlet {
// 是否安全?
//不安全,因为doGet方法可能操作修改map
Map<String,Object> map = new HashMap<>();
// 是否安全?
//是安全的,对于String的操作是安全的,因为内部其实是new出来的
String S1 = "...";
// 是否安全?同上
final String S2 = "...";
// 是否安全?不安全,因为同一个D1有可能被不同线程修改
Date D1 = new Date();
// 是否安全?不安全,final只能保证D2只能执行一处地方,但是不能保证里面的属性是不可变的
final Date D2 = new Date();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
// 使用上述变量
}
}
2、方法中-1
在这一类中主要看变量是不是成员变量,如果是就线程不安全,因为子类继承之后就使用了。
public class MyServlet extends HttpServlet {
// 是否安全?不安全,因为是成员变量,有可能被共享
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 记录调用次数
//线程安全,也是成员变量,那么多个线程就有可能共享到
private int count = 0;
public void update() {
// ...
count++;
}
}
3、方法中-2
这个更不用说了,MyAspect 创建出一个实例后,成员变量有可能被多个线程进行访问,我们可以使用环绕通知解决这个问题。
@Aspect
@Component
public class MyAspect {
// 是否安全?
private long start = 0L;
@Before("execution(* *(..))")
public void before() {
start = System.nanoTime();
}
@After("execution(* *(..))")
public void after() {
long end = System.nanoTime();
System.out.println("cost time:" + (end-start));
}
}
4、MVC架构-1
从最后一层往前看,UserDaoImpl这一层的update方法使用了String这一个安全类,只用了一个安全的局部变量,所以UserDaoImpl是线程安全的,那么上一层UserServiceImpl只调用了UserDaoImpl,所以这个也是线程安全的,而开始的MyServlet也只是调用了userService的update方法,只调用了成员变量的方法,也是安全的。最终下来其实效果是操作了Dao层里面的String类,String是线程安全的,所以这里也是线程安全的
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
public void update() {
String sql = "update user set password = ? where username = ?";
// 是否安全
try (Connection conn = DriverManager.getConnection("","","")){
// ...
} catch (Exception e) {
// ...
}
}
}
5、MVC架构-2
这个和上面大体是类似的,但是问题就在于Dao层有一个conn 成员变量而上面4是没有的,所以这里的MyServlet不是线程安全的。
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全
private Connection conn = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
6、MVC架构-3
这种情况是安全的,虽然Dao层conn是成员变量,但是在Service层调用的时候UserDao 是在方法里面调用的,所以下面的变量都是每个线程独有的。
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
public void update() {
UserDao userDao = new UserDaoImpl();
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全
private Connection = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
7、方法传出局部变量-1
这种是线程不安全的,虽然sdf是局部变量,但是通过foo方法传了出去,这就有可能导致sdf这个局部变量被多个线程共享,foo方法被子类重写后sdf变量是可以传到子类去的。其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法.
public abstract class Test {
public void bar() {
// 是否安全
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
public void foo(SimpleDateFormat sdf) {
String dateStr = "1999-10-11 00:00:00";
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
4. 习题分析(卖票和转账)
@Slf4j
public class ExerciseSell {
public static void main(String[] args) throws InterruptedException {
// 模拟多人买票
TicketWindow window = new TicketWindow(10000);
// 所有线程的集合
List<Thread> threadList = new ArrayList<>();
// 卖出的票数统计
List<Integer> amountList = new Vector<>();
for (int i = 0; i < 2000; i++) {
Thread thread = new Thread(() -> {
// 买票
//对共享变量读写操作,线程不安全
int amount = window.sell(random(5));
// 统计买票数
amountList.add(amount);
});
threadList.add(thread);
thread.start();
}
for (Thread thread : threadList) {
thread.join();
}
// 统计卖出的票数和剩余票数
log.debug("余票:{}",window.getCount());
log.debug("卖出的票数:{}", amountList.stream().mapToInt(i-> i).sum());
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~5
public static int random(int amount) {
return random.nextInt(amount) + 1;
}
}
// 售票窗口
class TicketWindow {
private int count;
public TicketWindow(int count) {
this.count = count;
}
// 获取余票数量
public int getCount() {
return count;
}
// 售票
public synchronized int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
}
@Slf4j
public class ExerciseTransfer {
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1000);
Account b = new Account(1000);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a.transfer(b, randomAmount());
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
b.transfer(a, randomAmount());
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 查看转账2000次后的总金额
log.debug("total:{}", (a.getMoney() + b.getMoney()));
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~100
public static int randomAmount() {
return random.nextInt(100) + 1;
}
}
// 账户
class Account {
private int money;
public Account(int money) {
this.money = money;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
// 转账
public void transfer(Account target, int amount) {
//保证线程安全
synchronized(Account.class) {
//synchronized(this)x:相当于锁this当前对象,只有this.money线程安全,target.getMoney()线程不安全
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
}
3. Monitor概念
3.1 Java 对象头
《Java并发编程》里面有对Java对象头的描写。synchronized用的锁是存在Java对象头里面的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1个字宽=4字节,即32bit,如表(Java对象头的长度)所示:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁消息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array length | 数组的长度(如果当前对象是数组) |
Java对象头里面的Mark Word里默认存储对象的HashCode、分代年龄、锁标记位。32位JVM的Mark Word的默认存储结构如表(Java对象头的存储结构)所示:
锁状态 | 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标记位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据,如表(Mark Word状态变化)所示:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如表所示,和32位的是分配的位数不同以及多了一个cms_free:
一个Java对象的存储结构就是如下:
Klass Word: 是java对象模型中的头部信息,用来描述java类,和虚拟机中的Java类型结构相对应着。
3.2 Monitor锁
Monitor被翻译为监视器或者说管程,是由操作系统提供的。
每个java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针。不加synchronized关键字的对象,是不会关联Monitor对象的。只有重量级锁对象才会关联Monitor。
- 刚开始时Monitor中的Owner为null
- 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,Monitor中同一时刻只能有一个Owner,同时obj中的Mark Word记录了这个Monitor的指针位置,相当于此时的obj和Monitor关联了
- 在Thread-2 上锁的时侯,如果线程Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList中变成BLOCKED状态
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的,根据JDK底层去看怎么竞争
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
3.3 synchronized原理
static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
从字节码层面看:流程就是获取lock,执行代码,释放lock和monitor,恢复对象头。如果出现异常还有肚子的处理。
反编译之后:
0 getstatic #2 <com/concurrent/test/Test17.lock>
# 取得lock的引用(synchronized开始了)
3 dup
# 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
4 astore_1
# 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
5 monitorenter
# 将lock对象的Mark Word置为指向Monitor指针,这时候关联了monitor,就会把hashcode这些都换成39字节的monitor
6 getstatic #3 <com/concurrent/test/Test17.counter>
9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
14 aload_1
# 从局部变量表中取得lock的引用,放入操作数栈栈顶
15 monitorexit
# 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8)
# 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
3.4 原理之 synchronized 进阶
1. 轻量级锁
轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。
- 轻量级锁对使用者是透明的,即语法仍然是synchronized
- 假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
流程如下:
-
每次指向到synchronized代码块时,都会在线程中创建一个栈帧,在栈帧中创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference,一开始对象头里面是01,表示没有加锁
-
让锁记录中的Object reference指向对象,并且尝试用cas替换Object的Mark Word ,将Mark Word 的值存入锁记录中
-
如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态00,如下所示,00表示轻量级锁
-
如果cas失败,有两种情况
1、如果是其它线程已经持有了该Object的轻量级锁,这时表示有竞争,将进入锁膨胀阶段
2、如果是自己执行了synchronized锁重入,那么再添加一条 Lock Record 作为重入的计数。锁的可重入表示自己的线程加了2次锁,null那里表示加了几次锁,lock Record。
-
当线程退出synchronized代码块(解锁的时候),如获取到取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出synchronized代码块(解锁)的时候,如果获取的锁记录取值不为 null,证明此时可以进行解锁了,这时使用cas将Mark Word的值恢复给对象。
1、成功,则解锁成功
2、失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2. 锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁(有竞争),这时候就需要膨胀成重量级锁。
static Object obj = new Object();
public static void method(){
synchronized(obj){
//操作
}
}
-
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁,已经是00(轻量级锁)状态了
-
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
1、即为对象申请Monitor锁,让Object指向重量级锁地址
2、自己进入Monitor 的EntryList 变成BLOCKED状态
3、Object的Mark Word中锁已经从00变成了10了,表示重量级锁
-
当Thread-0 退出synchronized同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败,因为此时Mark Word中的锁已经从00变成了10,重量级,要进入重量级锁的解锁流程:即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程,让Thread-1有机会获取到锁
3. 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时持锁退出了同步块,并释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁
自旋重试成功的情况(线程2自旋):线程不进入Entry List,而是一直while循环获取锁,所以这种情况适合多核CPU,单核CPU比较耗费资源,因为要一直在循环
自旋重试失败的情况(线程2自旋):自旋了一定次数还没获取到锁,进入Entry List进行阻塞
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势
- Java 7 之后不能控制是否开启自旋功能
4. 偏向锁
轻量级的锁在没有竞争的时候(只有自己一个线程),每次重入的时候仍然需要执行CAS操作,耗费时间
Java6开始引入了偏向锁来做进一步优化:只有第一次使用CAS时将线程ID设置到对象的Mark Word头中,之后线程在进行锁重入的时候发现这个ID是自己的,就可以不发生竞争,不用CAS,就可以加锁成功。
比如下面这些代码:
static final Object obj = new Object();
public static void m1(){
synchronized(obj){
m2();
}
}
public static void m2(){
synchronized(obj){
m3();
}
}
public static void m3(){
synchronized(obj){
//同步块C
}
}
5. 偏向状态
一个对象创建时:
- 如果开启了偏向锁(默认是开启的),那么对象创建之后,Mark Word 值为0x05即最后3位的值为101,这时它的thread,epoch,age都是0,这些在加锁的时候才会设置,现在是初始化
- 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以 VM 参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟
- 如果没有开启偏向锁,那么对象创建之后,Mark Word 值为0x01即最后3位的值为001,这时它的thread,epoch,age都是0,第一次用HashCode的时候才会赋值
- 测试代码:
@Slf4j
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Dog dog = new Dog();
String s = ClassLayout.parseInstance(dog).toPrintable();
log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog{}
- 这里打印出来的都是001,这时因为偏向锁是延时加载的,所以一开始还是001没有锁的状态,等一会之后才变成101。hashCode也是延时的
@Slf4j
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Dog dog = new Dog();
String s = ClassLayout.parseInstance(dog).toPrintable();
log.debug("{}", s);
// 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
Thread.sleep(4000);
log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
// 0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
}
}
class Dog{}
- 这种方法每次都要sleep,很麻烦,所以设置VM虚拟机参数来禁用延时,可以看到加锁中后都是101,其实这里我们开启偏向锁之后就不会考虑那些轻量级锁重量级锁了,一开始都用的轻量级。而且下面的Thread ID(这里没体现出来)是操作系统给我们分配的,和java里面的那个ID不是一回事。
@Slf4j
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(4000);
Dog dog = new Dog();
//加锁前: 0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
log.debug("{}", ClassLayout.parseInstance(dog).toPrintable());
synchronized (dog){
//加锁了: 0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
}
//加锁后: 0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog{}
测试撤销轻量级锁:
在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),按轻量级锁,重量级锁的流程来
测试hashCode: 当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为31位的hashCode没地方存了, 所以用偏向锁是没有hashCode的,但是这里又需要hashCode,所以就会自动撤销了。
- 轻量级锁会把hashCode存在锁记录里,
- 重量级锁会存在monitor里面,所以就没问题了。
@Slf4j
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(4000);
Dog dog = new Dog();
dog.hashCode();
log.debug("{}", ClassLayout.parseInstance(dog).toPrintable());
synchronized (dog){ log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
}
log.debug("{}", ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog{}
6. 偏向锁撤销
- 调用hashCode
- 其他线程使用对象
我们做一个代码测试,演示的是偏向锁撤销变成轻量级锁的过程,一个线程先加上轻量级锁,然后解锁,接着第二个线程加锁,这时候就升级了,因为第一个线程加完锁后按理来说这个对象已经是属于这个线程的了,内部保留了这个线程的信息,这时候又遇到了线程2,只能升级保证锁竞争。
代码准备:
- 虚拟机参数-XX:BiasedLockingStartupDelay=0表示使用偏向锁
- 使用wait和notify来进行线程间的通信
// 测试撤销偏向锁,t1先获取锁,然后释放给t2获取,在t1对线程1使用偏向锁后,线程2再获取的时候就会把
//偏向锁升级为轻量级锁
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}, "t2");
t2.start();
}
可以看到,t2开始加锁的时候从101变成了000轻量级锁。
- 调用wait -notify,因为这种机制只有重量级锁才有的。所以调用的时候会升级位重量级锁。
7. 批量重偏向
如果对象被多个线程访问,但是没有竞争,这时候偏向了线程T1的对象有机会重新偏向线程T2,重偏向会重置对象。但是由于我们之前说过如果有线程竞争是需要升级成轻量级锁的,所以要想达到重偏向,是有条件的:
- 当撤销偏向锁的阈值超过20次之后,也就是说线程进行加锁的时候撤销了20次(阈值20,所以在19就能看到效果),这时候JVM就会思考是不是偏向错了。注意这里的撤销次数是指线程想要加锁时撤销的次数。
- 所以从20次之后的对象再遇到这个线程的时候就会把偏向锁设置到这个线程上
- 举个例子:原来有30个对象偏向锁给了t1,这时候t2线程要对这30个对象都执行synchronized(obj)操作,自然到19位置的时候偏向锁被撤销升级了20次,从19开始往后的所有对象,再执行加锁的时候就不会撤销了,而是重新偏向t2。
8. 批量撤销
当撤销偏向锁阈值超过40次后,jvm会觉得自己确实偏向错了,这个类竞争比较激烈,就不该偏向的。于是整个类的所有的对象都会变成不可偏向的,新建的对象也是不可偏向的。这里撤销次数是指所有的线程在这个类中被撤销的次数,和上面的道理是一样的。
9. 锁消除
结果:我们发现明明方法2有一个syn锁,为什么性能一个1.569,一个1.603差不多呢?这时因为Java中有一个JIT即时编译器,Java是解释+编译的执行方式,JIT对Java使用解释执行,对于里面可以优化的地方会帮我们进行优化。所以下面JIT发现方法b中的局部变量根本不会逃离作用范围,也就是说其他线程都没办法共享到这一数据,那么加syn锁就没有任何意义,JIT就会帮我们去掉这一行,这样就导致性能是很接近了。
我们也可以在运行jar的之后这样运行,意思是消除锁消除,可以看到的是结果性能确实慢了很多。
如有错误,欢迎指出