Understanding the JVM:线程安全与锁优化,Java并发:性能调优

这一章是讲线程安全和锁优化的,其中线程安全确实是一个和程序息息相关的问题,而锁优化是为了使JVM的效率更好。所以本章的重点放在线程安全这一点。(多线程实现程序的并发执行,提升运行效率,但多线程时,需要解决线程安全问题)

随着计算机技术的发展,追求更高性能的服务越来越重要。但是问题也随之而来,因为性能提升是建立在并发的基础上,而并发遇到的数据同步问题又非常令人头疼。所以要保证:

  • 程序是线程安全的,即保证程序的正确性
  • 在正确的前提下,优化代码提高性能

下面就来说说线程安全的问题。

线程安全第一次碰见是在实习的时候,我写了一个模块,被问到是不是线程安全的。当时不知道啥意思- -!然后赶紧去看了相关资料,最后把模块改成线程安全的了。用我的话总结一下就是:

当外部多个线程调用这个模块的时候,数据不会因为多线程读写造成数据不一致,就可以说是线程安全了。

今天看到了一个更为严谨的定义(但是很晦涩):

线程安全是指当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

大白话:多个线程访问同一个对象时,并发读、并发写,是否会产生不确定性,是否会产生相互干扰。

也就是说,如果一个方法封装了所有必要的正确性保证手段,能保证谁调用它都不用操心多线程相关的问题,也不用进行额外的保护措施,那么它就是线程安全的。

我们可以根据线程安全的”安全程度“由强至弱来排序,将Java语言中各种操作共享的数据分为五类

  1. 不可变
  2. 绝对线程安全
  3. 相对线程安全
  4. 线程兼容
  5. 线程独立

下面我们就逐个说明吧:)

不可变

不可变对象一定是线程安全的,因为它根本不变呀!!比如final、String、枚举、java.lang.Number的部分子类如Long、Double、BigInteger、BigDecimal等

绝对线程安全

绝对的东西应该很少存在,如果想实现绝对线程安全是要付出巨大代价的,甚至有些情况下根本不可能实现绝对线程安全。在Java API中标榜自己是线程安全的类,大多数都不会绝对的线程安全。我们知道,java.util.Vector是一个线程安全的容器,它的add()/get()/size()方法都是被synchronized修饰的,尽管这样效率很低,但是确实是安全的。悲剧的是,即使它所有的方法都被修饰成synchronized,也不意味着调用它的时候永远都不再需要同步手段了。下面就是打脸时刻

  1. public class VectorTest {
  2. public static Vector<Integer> vector = new Vector<Integer>();
  3. public static void main(String[] args) throws InterruptedException {
  4. while (true) {
  5. for (int i = 0; i < 10; i++) {
  6. vector.add(i);
  7. }
  8. Thread.sleep(50);
  9. Thread removeThread = new Thread(new Runnable() {
  10. @Override
  11. public void run() {
  12. for (int i = 0; i < vector.size(); i++) {
  13. vector.remove(i);
  14. }
  15. }
  16. });
  17. Thread printThread = new Thread(new Runnable() {
  18. @Override
  19. public void run() {
  20. for (int i = 0; i < 10; i++) {
  21. System.out.println(vector.get(i));
  22. }
  23. }
  24. });
  25. removeThread.start();
  26. printThread.start();
  27. while (Thread.activeCount() > 20)
  28. ;
  29. }
  30. }
  31. }

额。。。作者说代码有问题,但是我跑了5分钟也没抛出异常= =不过想想也有可能,比如remove删除了i元素,那么get()的时候就可能越界了。。。所以这里加上一个Vector的对象锁最为合适。

相对线程安全

相对线程安全就是通常意义上的线程安全,它需要保证对这个对象的单独操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于多个线程的调用,就需要在调用端使用额外的同步手段来保证调用的正确性。

在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端使用同步手段来保证对象在并发环境中安全滴使用。我们平常说一个类不是线程安全的,就是指这种情况。Java API中大部分的类都是线程兼容的,比如ArrayList和HashMap(非线程安全,说的就是你!原来是线程兼容的呀)等。

线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。但是因为Java语言天生就具有多线程特性,所以这种代码是极少的,完全可以忽略。

了解了线程安全后,我们就要在写代码的时候保证这一点。而怎么写出线程安全的代码呢?有如下几个方法:

这个是针对临界资源的,互斥同步是最常见的一种并发正确性保证手段。在Java里,最基本的互斥同步手段就是synchronized关键字。synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象

如果某个线程取得锁,那么其他线程再取锁的时候就会发现已经被锁定,要使用的话就必须阻塞直到那个线程把锁释放。

而除了synchronized之外,我们还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock和synchronized相似,都具备一样的线程重入性,只是代码写法上有点区别,一个表现为API层面的互斥锁,一个表现为原生语法层面的互斥锁。不过ReentrantLock比synchronized增加了一些高级功能,主要有:

  • 等待可中断:指持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理时间非常长的同步块很有帮助
  • 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获取锁;非公平锁则不能保证这一点:锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平锁,但是可通过带boolean的构造函数要求使用公平锁
  • 锁绑定多个条件:指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁。

经过上面的描述,我们可以简单的认为ReentrantLock比synchronized多了几个特性,所以在使用到那些特性的时候选择合适的方法就可以了。至于效率问题,在JDK比较老的版本两者性能差距较大,但随着JDK的优化,两者的性能几乎相差无几。所以选择的关键就是使用场景了。

互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题,因此这种同步也被称为阻塞同步。同时,这也是一种悲观的并发策略:总是认为只要不去做正确的同步措施就肯定会出问题。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗的说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据存在竞争,就再进行补偿措施(最常见的就是不断重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步

还记得上面volatile实现的那个例子吗?结果不是200000,因为race的自增操作不是原子性的,这里可以使用原子性的AtomicInteger来完成,代码如下:

还记得上面volatile实现的那个例子吗?结果不是200000,因为race的自增操作不是原子性的,这里可以使用原子性的AtomicInteger来完成,代码如下:

  1. import java.util.concurrent.atomic.AtomicInteger;
  2. public class AtomicTest {
  3. public static volatile AtomicInteger race = new AtomicInteger();
  4. public static void increase() {
  5. race.incrementAndGet();
  6. }
  7. private static final int THREADS_COUNT = 20;
  8. public static void main(String[] args) {
  9. Thread[] threads = new Thread[THREADS_COUNT];
  10. for(int i = 0; i < THREADS_COUNT; i++) {
  11. threads[i] = new Thread(new Runnable() {
  12. @Override
  13. public void run() {
  14. for(int i = 0; i < 10000; i++) {
  15. increase();
  16. }
  17. }
  18. });
  19. threads[i].start();
  20. }
  21. while(Thread.activeCount() > 1) {
  22. Thread.yield();
  23. }
  24. System.out.println(race);
  25. }
  26. }

因为AtomicInteger的incrementAndGet()方法是原子性的,所以这里不会出现任何问题。

Note:原子性和内存可见性,要同时使用,才能保证并发自增操作是正确的,即,需要同时配置:AtomicInteger 和 volatile

高效并发永远是一个热门的话题,HotSpot虚拟机开发团队花费了大量的精力去实现各种锁优化技术,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

至于具体的技术,这里就不细讲了,对锁机制有兴趣的可以深入了解一下。

Java并发:性能调优

这个小节主要讲的是优化,但是高德纳不是说过嘛——Premature optimization is the root of all evil.所以等到真正发现有问题再仔细研究,现在看看估计就忘了。所以我先大概了解一下关于锁的问题,其他等遇到具体场景再说:)

书中上来就先道出了免锁容器背后的通用策略:

对容器的修改可以与读操作同时发生,只要读取者只能看到完成修改的结果即可。修改是在容器数据结构的某个部分的一个单独的副本(比如 ConcurrentHashMap 分段锁表,那么就是一段的副本,有时是整个数据结构的副本)上执行的,并且这个副本在修改过程中是不可视的(不然读成脏数据了)。只有当修改完成时,被修改的结构才会自动地与主数据结构进行交换,之后读取者就可以看到这个修改了。 然后作者写了一个测试框架,旨在比较加锁容器和免锁容器在性能上的差异。很明显,免锁容器去掉了获取、销毁锁的开销,肯定会有性能的提升….

如果有兴趣,建议搜索一下 ConcurrentHashMap 相关的文章,知道它为什么能用于并发场景(因为有 N 个Segement 重入锁,每个 Segement 锁管理一个 HashEntry 数组),实现的效率如何(分段锁表而非锁整表),有什么弱点(弱一致性)?网上有很多分析源码的文章,大概了解一下原理也不是什么难事。

先说明为什么需要锁呢?在多用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突。这就是著名的并发性问题。典型的冲突有:

  • 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失。例如:用户A把值从6改为2,用户B把值从2改为6,则用户A丢失了他的更新。
  • 脏读:当一个事务读取其它完成一半事务的记录时,就会发生脏读取。例如:用户A,B看到的值都是6,用户B把值改为2,用户A读到的值仍为6。 为了解决这些并发带来的问题。 我们需要引入并发控制机制。

最常用的处理多用户并发访问的方法是加锁。当一个用户锁住数据库中的某个对象时,其他用户就不能再访问该对象。加锁对并发访问的影响体现在锁的粒度上。比如,放在一个表上的锁限制对整个表的并发访问;放在数据页上的锁限制了对整个数据页的访问;放在行上的锁只限制对该行的并发访问。可见行锁粒度最小,并发访问最好,页锁粒度最大,表锁介于2者之间。

  • 悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。[1] 悲观锁假定其他用户企图访问或者改变你正在访问、更改的对象的概率是很高的,因此在悲观锁的环境中,在你开始改变此对象之前就将该对象锁住,并且直到你提交了所作的更改之后才释放锁。悲观的缺陷是不论是页锁还是行锁,加锁的时间可能会很长,这样可能会长时间的限制其他用户的访问,也就是说悲观锁的并发访问性不好。
  • 乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。[1] 乐观锁不能解决脏读的问题。 乐观锁则认为其他用户企图改变你正在更改的对象的概率是很小的,因此乐观锁直到你准备提交所作的更改时才将对象锁住,当你读取以及改变该对象时并不加锁。可见乐观锁加锁的时间要比悲观锁短,乐观锁可以用较大的锁粒度获得较好的并发访问性能。但是如果第二个用户恰好在第一个用户提交更改之前读取了该对象,那么当他完成了自己的更改进行提交时,数据库就会发现该对象已经变化了,这样,第二个用户不得不重新读取该对象并作出更改。这说明在乐观锁环境中,会增加并发用户读取对象的次数。

下面通过一个小例子来说明 AtomicInteger 的乐观锁使用:

  1. package concurrency;
  2. import java.util.Random;
  3. import java.util.concurrent.ExecutorService;
  4. import java.util.concurrent.Executors;
  5. import java.util.concurrent.TimeUnit;
  6. import java.util.concurrent.atomic.AtomicInteger;
  7. // 模拟遗传算法
  8. public class FastSimulation {
  9. // 有10万个基因
  10. static final int N_ELEMENTS = 100000;
  11. // 每个基因长度为30
  12. static final int N_GENES = 30;
  13. //一共进化50次
  14. static final int N_EVOLVERS = 50;
  15. static final AtomicInteger[][] GRID = new AtomicInteger[N_ELEMENTS][N_GENES];
  16. static Random rand = new Random(47);
  17. static class Evolver implements Runnable {
  18. public void run() {
  19. while (!Thread.interrupted()) {
  20. int element = rand.nextInt(N_ELEMENTS);
  21. for (int i = 0; i < N_GENES; i++) {
  22. int previous = element - 1;
  23. if (previous < 0) {
  24. previous = N_ELEMENTS - 1;
  25. }
  26. int next = element + 1;
  27. if (next >= N_ELEMENTS) {
  28. next = 0;
  29. }
  30. int oldValue = GRID[element][i].get();
  31. // 前后三值取平均值
  32. int newValue = oldValue + GRID[previous][i].get() + GRID[next][i].get();
  33. newValue /= 3;
  34. // 乐观锁用法,因为没有任何锁机制。
  35. // 只有当想更新 GRID[element][i]的值,但是其值已经发生变化的情况下(不和 oldValue 相同), 才进行
  36. // 失败操作,这里只是打印一下。
  37. if (!GRID[element][i].compareAndSet(oldValue, newValue)) {
  38. System.out.println("Old value changed from " + oldValue + " to " + GRID[element][i]);
  39. }
  40. }
  41. }
  42. }
  43. }
  44. public static void main(String[] args) throws Exception {
  45. ExecutorService exec = Executors.newCachedThreadPool();
  46. for (int i = 0; i < N_ELEMENTS; i++)
  47. for (int j = 0; j < N_GENES; j++)
  48. GRID[i][j] = new AtomicInteger(rand.nextInt(1000));
  49. // 进化50次
  50. for (int i = 0; i < N_EVOLVERS; i++)
  51. exec.execute(new Evolver());
  52. TimeUnit.SECONDS.sleep(5);
  53. exec.shutdownNow();
  54. }
  55. }

上面说明这个类的使用场景:

对向数据结构多读少写的情况进行了优化。使得可以有多个读取者,只要他们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直到这个写锁被释放。所以,这个性能到底如何是不能提前预知的,只有通过不断的测试你的程序性能才能验证。当然,前提是你知道这个类的应用场景:多读少写。 因为作者也说了,这是一个相当复杂的工具,只有当程序性能出现瓶颈的时候才会被考虑,所以这里有个大概的印象即可,另外还有一个 ReentrantReadWriteLock 工具类。

 

同类文章:

posted @ 2021-12-11 22:22  CharyGao  阅读(20)  评论(0)    收藏  举报