在 Java 里面我们可能会遇到由于资源共享导致线程安全的问题,经常性的就是多个线程同一时间对共享的资源数据进行修改,这时可能就会出现数据不是我们预期的情况。
拿一个通俗点的例子来说的话就是多个线程对同一个账户进行扣款操作,比如说下面的例子:
1 | private static int accountBalance = 10; |
1 |
|
上面代码中很简单,大体就是模拟账户余额剩余 10 元,然后有两个线程同时进行消费,两个线程都是消费 10 元,消费方法里面有个判断当账户余额不足 10 元会打印账户余额不足,扣款失败,但是执行上面代码后发现两个线程都消费成功了,最后打印账户余额变成了 -10 元,甚至有时候还会出现 0 元的情况,很明显这不是我们预期的结果,我们想要的是账户余额不足时扣款失败,但上面都成功了,然后账户余额变成了负数。这就是由于多个线程同时对同一个账户进行消费扣款而没有做相应的线程安全的保证措施而导致的,这只是一个很简单的例子演示了多线程下出现的问题,那么在我们日常开发中有哪些措施来保证线程安全呢。
Java 中保证线程安全的通用做法是通过 synchronized 关键字来对方法或者代码块进行加锁,比如说上面的代码中对 consume 方法进行加锁,这样就能够保证每次只有一个线程能够进入 consume 方法来进行扣款操作,相当于让扣款操作同步执行,这样就不会出现多个线程同时对账户扣款导致出现问题,consume 方法加上 synchronized 关键字之后:
1 | private static synchronized void consume(int amount){ |
上面由于我为了简单起见,方法直接声明成静态的,所以在方法上加锁的话,锁定的是类对象,不同线程执行该方法需要先获取该类对象锁,如果锁已经被其他线程占有,那么就需要等待其他线程释放锁。但是这样直接对整个方法进行加锁可能有点太粗暴了,其实如果方法中还有其他操作的话,我们往往只是需要保证方法中的一小部分代码是线程安全的即可,假如 consume 方法在扣款前还需要对账户进行校验,以验证账户是否正常,那么校验账户是否正常的这部分代码就可以不用进行同步,可以并发执行,而只要对扣款的那部分代码加锁即可:
1 | private static void consume(int amount){ |
上面 consume 方法只对扣款部分代码进行加锁,加锁的方式和上面其实是一样的,也是通过该类对象来加锁,只不过锁定的范围缩小了,锁定的只是扣款的那部分操作,对于账户校验的操作依然是并发执行,这样可以提高程序执行的最大并发度,从而提高程序执行效率。
其实上面的代码看起来好像已经是最大限度的并发执行了,但是上面的代码演示的是同一个用户同时进行消费,你可以试想一下如果是两个用户在同时进行消费操作呢,那么找上面的代码执行的话,他们的消费的顺序也是依次按序执行的。
这里用 A 和 B 来代表两个不同用户,A1,A2 表示 A 用户同时进行的两笔消费,B1,B2 表示 B 用户同时进行的两笔消费,那么当 A,B 用户同时进行消费的时候,按照上面的代码执行逻辑,那就可能产生这种消费顺序: A1 -> A2 -> B1 -> B2,当然这只是可能的消费顺序中其中一种,也有可能 B1 -> B2 -> A1 -> A2,A1 -> B2 -> B1 -> A2 等等一些。
但不管是哪一种,由于我们通过 synchronize 关键字来保证线程安全,那么这四笔消费都是按顺序依次进行扣款的,但其实呢,这里 A,B 两个用户之间的扣款是没有关联的,因为他们隶属于不同的账户,也就是说他们之间的扣款是可以并发执行的,而不需要按序来进行扣款,所以我们只要保证同一个用户在同一时间的消费扣款是顺序执行的就可以了。因此我们需要通过更细粒度的锁来实现只在同一用户下不同线程之间的扣款是顺序执行,而不同用户之间的扣款依然是并发执行。
那这种用户级别的细粒度锁又该如何来实现呢?首先我们先来梳理一下我们要实现的预期效果,对于 A,B 两个用户来说,他们之间是不同的账户,他们之间互相的扣款顺序是互不影响的,我们最终仅仅是要保证每个用户他自己在多线程扣款的情况下是安全的即可。那么我们在加锁的时候就应该对该用户进行加锁,A 用户的 T1 线程执行扣款操作时,如果已经有其他线程 T2 已经获取了 A 用户的锁正在执行扣款操作的话,那么 T1 线程就需要等待 T2 线程释放 A 用户的锁才能继续往下执行。而这时如果 B 用户的一个线程也来执行扣款操作,那么 B 用户扣款操作的线程需要获取的是 B 用户的锁,如果 B 用户没有其他线程正在执行扣款,那么 B 用户就可以正常执行扣款,与 A 用户是否存在扣款操作线程无关。
说了这么多,其实也就是我们的程序在加锁时只要锁定和每个用户各自相关的对象锁,不同用户之间互不影响即可,我们来看看一种简单的用户级别细粒度锁是如何实现的:
1 |
|
上面的代码从最初的单个用户扣款经过简单的扩展模拟多个用户进行消费,同时在消费方法加上了用户 id 参数来决定扣款的账户是哪一个,然后使用 Map 来保存用户的账户余额信息。在扣款时先去 Map 中取对应用户的账户然后再执行扣款操作。
但要注意的是上面加锁的代码中加锁的对象是用户字符串 id 调用 intern() 方法返回的值,那 String 类中的 intern 方法是干嘛的呢?简单的说是一个字符串对象调用此方法时,如果字符串常量池中已经包含了该字符串,则直接返回常量池中该字符串的引用地址,否则该字符串对象的引用地址将会被加入到常量池中,然后再返回该引用地址。也就是说一个字符串对象的地址通过调用 intern 方法之后,那么最终在字符串常量池中会生成一份唯一的值,所以我们通过 synchronize 关键字加锁的这个对象是用户 id 字符串在字符串常量池中唯一对象的地址值。(这里可能有点不太好理解,intern 方法下次再用一篇文章单独详细介绍下)
那么接下来再回到用户 A,B 同时消费的情况,由于我们锁定的是用户 id,那么当用户 A,B 同时进行消费,A 用户获取的是 A 用户 id 的对象锁,而 B 用户获取的是 B 用户 id 的对象锁,不同用户获取的锁是不同的,所以他们之间互不影响,那么他们在同时进行消费扣款时就能同时进入加锁的代码块执行扣款,但相同用户的不同线程则还是会等同一用户的其他线程释放锁之后才能进入加锁的代码块。
虽然说这种实现方式实现起来相对较简单,但是这种通过 intern 方法的实现方式也是有缺陷的,当用户量特别大的情况下就会有问题了,这时字符串常量池中可能会存储大量的字符串引用,这其实就涉及到 JVM 虚拟机的存储结构了以及 GC 相关的知识了,字符串常量池这块区域相对于 GC 的主要区域堆区来说,这块区域 GC 比较困难,因此一般也不太建议采用这种方式来实现用户级别的细粒度锁,下篇文章我们就来看下比较常见的细粒度锁的实现方式有哪些。