Java线程安全面试题,你真的了解吗?
多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。
线程安全有以下几种实现方式:
不可变
不可变(immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
- final 关键字修饰的基本数据类型
- string
- 枚举类型
- number 部分子类,如 long 和 double 等数值包装类型,biginteger 和 bigdecimal 等大数据类型。但同为 number 的原子类 atomicinteger 和 atomiclong 则是可变的。
对于集合类型,可以使用 collections.unmodifiablexxx() 方法来获取一个不可变的集合。
public class immutableexample { public static void main(string[] args) { map<string, integer> map = new hashmap<>(); map<string, integer> unmodifiablemap = collections.unmodifiablemap(map); unmodifiablemap.put("a", 1); } } exception in thread "main" java.lang.unsupportedoperationexception at java.util.collections$unmodifiablemap.put(collections.java:1457) at immutableexample.main(immutableexample.java:9)
collections.unmodifiablexxx() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
public v put(k key, v value) { throw new unsupportedoperationexception(); }
互斥同步
synchronized 和 reentrantlock。
非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
1. cas
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(compare-and-swap,cas)。cas 指令需要有 3 个操作数,分别是内存地址 v、旧的预期值 a 和新值 b。当执行操作时,只有当 v 的值等于 a,才将 v 的值更新为 b。
2. atomicinteger
j.u.c 包里面的整数原子类 atomicinteger 的方法调用了 unsafe 类的 cas 操作。
以下代码使用了 atomicinteger 执行了自增的操作。
private atomicinteger cnt = new atomicinteger(); public void add() { cnt.incrementandget(); }
以下代码是 incrementandget() 的源码,它调用了 unsafe 的 getandaddint() 。
public final int incrementandget() { return unsafe.getandaddint(this, valueoffset, 1) + 1; }
以下代码是 getandaddint() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getintvolatile(var1, var2) 得到旧的预期值,通过调用 compareandswapint() 来进行 cas 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 getandaddint() 在一个循环中进行,发生冲突的做法是不断的进行重试。
public final int getandaddint(object var1, long var2, int var4) { int var5; do { var5 = this.getintvolatile(var1, var2); } while(!this.compareandswapint(var1, var2, var5, var5 + var4)); return var5; }
3. aba
如果一个变量初次读取的时候是 a 值,它的值被改成了 b,后来又被改回为 a,那 cas 操作就会误认为它从来没有被改变过。
j.u.c 包提供了一个带有标记的原子引用类 atomicstampedreference 来解决这个问题,它可以通过控制变量值的版本来保证 cas 的正确性。大部分情况下 aba 问题不会影响程序并发的正确性,如果需要解决 aba 问题,改用传统的互斥同步可能会比原子类更高效。
无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
1. 栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
public class stackclosedexample { public void add100() { int cnt = 0; for (int i = 0; i < 100; i++) { cnt++; } system.out.println(cnt); } } public static void main(string[] args) { stackclosedexample example = new stackclosedexample(); executorservice executorservice = executors.newcachedthreadpool(); executorservice.execute(() -> example.add100()); executorservice.execute(() -> example.add100()); executorservice.shutdown(); }
100
100
2. 线程本地存储(thread local storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 web 交互模型中的“一个请求对应一个服务器线程”(thread-per-request)的处理方式,这种处理方式的广泛应用使得很多 web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 java.lang.threadlocal 类来实现线程本地存储功能。
对于以下代码,thread1 中设置 threadlocal 为 1,而 thread2 设置 threadlocal 为 2。过了一段时间之后,thread1 读取 threadlocal 依然是 1,不受 thread2 的影响。
public class threadlocalexample { public static void main(string[] args) { threadlocal threadlocal = new threadlocal(); thread thread1 = new thread(() -> { threadlocal.set(1); try { thread.sleep(1000); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println(threadlocal.get()); threadlocal.remove(); }); thread thread2 = new thread(() -> { threadlocal.set(2); threadlocal.remove(); }); thread1.start(); thread2.start(); } }
1
为了理解 threadlocal,先看以下代码:
public class threadlocalexample1 { public static void main(string[] args) { threadlocal threadlocal1 = new threadlocal(); threadlocal threadlocal2 = new threadlocal(); thread thread1 = new thread(() -> { threadlocal1.set(1); threadlocal2.set(1); }); thread thread2 = new thread(() -> { threadlocal1.set(2); threadlocal2.set(2); }); thread1.start(); thread2.start(); } }
它所对应的底层结构图为:
每个 thread 都有一个 threadlocal.threadlocalmap 对象。
/* threadlocal values pertaining to this thread. this map is maintained * by the threadlocal class. */ threadlocal.threadlocalmap threadlocals = null;
当调用一个 threadlocal 的 set(t value) 方法时,先得到当前线程的 threadlocalmap 对象,然后将 threadlocal->value 键值对插入到该 map 中。
public void set(t value) { thread t = thread.currentthread(); threadlocalmap map = getmap(t); if (map != null) map.set(this, value); else createmap(t, value); }
get() 方法类似。
public t get() { thread t = thread.currentthread(); threadlocalmap map = getmap(t); if (map != null) { threadlocalmap.entry e = map.getentry(this); if (e != null) { @suppresswarnings("unchecked") t result = (t)e.value; return result; } } return setinitialvalue(); }
threadlocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。
在一些场景 (尤其是使用线程池) 下,由于 threadlocal.threadlocalmap 的底层数据结构导致 threadlocal 有内存泄漏的情况,应该尽可能在每次使用 threadlocal 后手动调用 remove(),以避免出现 threadlocal 经典的内存泄漏甚至是造成自身业务混乱的风险。
3. 可重入代码(reentrant code)
这种代码也叫做纯代码(pure code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
免费java高级资料需要自己领取,涵盖了java、redis、mongodb、mysql、zookeeper、spring cloud、dubbo高并发分布式等教程,一共30g。
传送门:https://mp.weixin.qq.com/s/jzddfh-7ynudmkjt0irl8q
上一篇: .net core中Quartz的使用
下一篇: 明明看到了商机,为什么就是赚不到钱?