【Java并发编程】9.线程安全与性能的思考
线程安全
定义:Doug Lea 大佬说过 如果多线程情况下使用这个类,无论多线程如何使用和调度这个类,这个类总是表示出正确的行为,这个类就是线程安全的。
类的线程安全表现为:
- 操作的原子性,类似数据库事务。
- 内存的可见性,当前线程修改后其他线程立马可看到。
不做正确的同步,在多个线程之间共享状态的时候,就会出现线程不安全。
安全策略
1. 栈封闭
所有的变量都是在方法内部声明的,这些变量都处于栈封闭状态。方法调用的时候会有一个栈桢,这是一个独立的空间。在这个独立空间创建跟使用则绝对是安全的,但是注意不要返回该变量哦!
public void JustTest(){
Integer a = 12;
User user = new User(12);
}
2. 无状态
说白了就是这个类没有任何成员变量,只有一堆成员函数,这样绝对是安全的。
public class StatelessClass {
public int service(int a,int b) {
return a*b;
}
//...public void t(){}
}
3. 类不可变
Java中不管是String对象跟基本类型装箱后的对象都是不可变的,说白了就是都带有final
。让状态不可变,两种方式:
- 加final关键字,对于一个类,所有的成员变量应该是私有的,同样的只要有可能,所有的成员变量应该加上
final
关键字,但是加上final
,要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。- 根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值,说白了就是不提供任何
set
方法。
public class ImmutableFinalRef {
private final int a;
private final int b;
private final User user; //这里,就不能保证线程安全啦
public ImmutableFinalRef(int a, int b) {
super();
this.a = a;
this.b = b;
this.user = new User(2);
}
public int getA() {
return a;
}
public int getB() {
return b;
}
public User getUser() {
return user;
}
public static class User{
private int age;
//private final int age;
public User(int age) {
super();
this.age = age;
}
public int getAge() {return age;}
public void setAge(int age) {this.age = age;}
}
public static void main(String[] args) {
ImmutableFinalRef ref = new ImmutableFinalRef(12,23);
User u = ref.getUser();
u.setAge(35);// 对象不可变不过对象里的数据也要不可变才可以!
}
}
volatile
保证类的可见性,最适合一个线程写,多个线程读的情景,再重复一遍 只保证变量的可见性!用volatile
修饰的变量在get
的时候多线程情况下不用加锁,保证可见性。但是在set的时候要加锁或者通过CAS操作进行变化。
比如ConcurrentHashMap
。
加锁跟CAS
前面的若干章节都写过 对于操作变量用syn,lock,CAS。
安全发布
类中持有的成员变量,特别是对象的引用,如果这个成员对象不是线程安全的,通过get等方法发布出去(return出去),会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。
解决方法:用concurrentLinkedQueue等线程安全容器或者返回一个副本。
public class UnsafePublish {
//要么用线程的容器替换,要么发布出去的时候,提供副本,深度拷贝
private List<Integer> list = new ArrayList<>(3);
public UnsafePublish() {
list.add(1);
list.add(2);
list.add(3);
}
//将list不安全的发布出去了
public List<Integer> getList() {return list;}
//也是安全的,加了锁
public synchronized int getList(int index) {
return list.get(index);
}
public synchronized void set(int index,int val) {
list.set(index, val);
}
}
TheadLocal
底层类似跟一个HashMap一样简单理解,key = 线程,value就是当前线程使用的变量。
死锁
竞争的资源一定是多于1个,同时小于等于竞争的线程数,资源只有一个,只会产生激烈的竞争。
死锁的根本成因:获取锁的顺序不一致导致。
public class NormalDeadLock {
private static Object valueFirst = new Object();//第一个锁
private static Object valueSecond = new Object();//第二个锁
//先拿第一个锁,再拿第二个锁
private static void fisrtToSecond() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (valueFirst) {
System.out.println(threadName + " 获得第一个");
TimeUnit.MILLISECONDS.sleep(100);
synchronized (valueSecond) {
System.out.println(threadName + " 获得第二个");
}
}
}
//先拿第二个锁,再拿第一个锁
private static void SecondToFisrt() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (valueSecond) {
System.out.println(threadName + " 获得第一个");
TimeUnit.MILLISECONDS.sleep(100);
synchronized (valueFirst) {
System.out.println(threadName + " 获得第二个");
}
}
}
//执行先拿第二个锁,再拿第一个锁
private static class TestThread extends Thread {
private String name;
public TestThread(String name) {
this.name = name;
}
public void run() {
Thread.currentThread().setName(name);
try {
SecondToFisrt();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Thread.currentThread().setName("TestDeadLock");
TestThread testThread = new TestThread("SubTestThread");
testThread.start();
try {
fisrtToSecond();//先拿第一个锁,再拿第二个锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
如果怀疑发送死锁:
- 通过
jps
查询应用的id
,- 通过
jstack id
查看应用的锁的持有情况
简单来说就是 甲拿着A锁,获取B锁,乙拿着B锁获取A锁,注意在甲乙获得第一个锁的时候休眠会儿,来制造死锁。
解决方法:保证加锁的顺序性。
- 设定为锁定AB的时候,总是先锁小的再锁大的。可以通过甲乙的唯一ID活着通过System自带的获得ID函数
System.identityHashCode();
public class SafeOperate implements ITransfer {
private static Object tieLock = new Object();//加时赛锁
@Override
public void transfer(UserAccount from, UserAccount to, int amount)
throws InterruptedException {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
// 或者你可以保证 ID唯一可以用ID实现
//先锁hash小的那个
if(fromHash<toHash) {
synchronized (from){
System.out.println(Thread.currentThread().getName() +" get"+from.getName());
Thread.sleep(100);
synchronized (to){
System.out.println(Thread.currentThread().getName() +" get"+to.getName());
from.flyMoney(amount);
to.addMoney(amount);
}
}
}else if(toHash<fromHash) {
synchronized (to){
System.out.println(Thread.currentThread().getName() +" get"+to.getName());
Thread.sleep(100);
synchronized (from){
System.out.println(Thread.currentThread().getName() +" get"+from.getName());
from.flyMoney(amount);
to.addMoney(amount);
}
}
}else {//解决hash冲突的方法
synchronized (tieLock) { //那个线程拿到再处理
synchronized (from) {
synchronized (to) {
from.flyMoney(amount);
to.addMoney(amount);
}
}
}
}
}
}
- 通过tryLock
核心思路就是while死循环获得两个锁,都获得才可以进行操作然后break。
public void transfer(UserAccount from, UserAccount to, int amount)
throws InterruptedException {
Random r = new Random();
while(true) {
if(from.getLock().tryLock()) {
try {
System.out.println(Thread.currentThread().getName() +" get "+from.getName());
if(to.getLock().tryLock()) {
try {
System.out.println(Thread.currentThread().getName() +" get "+to.getName());
//两把锁都拿到了
from.flyMoney(amount);
to.addMoney(amount);
break;
}finally {
to.getLock().unlock();
}
}
}finally {
from.getLock().unlock();
}
}
SleepTools.ms(r.nextInt(10)); // 防止发生活锁!
}
}
活锁
尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生拿锁,释放锁的过程。
比如上面的活锁代码while循环如果没有时间休眠的话,由于JDK线程获得锁是谦让式获得的,可能出现如下:
甲拿到A尝试拿B,拿B失败了再重新尝试拿A,再重新拿B,这样周而复始的尝试。
乙拿到B尝试拿A,拿A失败了再重新尝试拿B,再重新拿A,这样周而复始的尝试。
解决办法:把对象加锁顺序的不确定性变成确定性的顺序。
解决:
- 通过内在排序,保证加锁的顺序性
- 通过尝试拿锁配合休眠若干也可以。
线程饥饿
饥饿:线程因无法访问所需资源而无法执行下去的情况。
不患寡,而患不均,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程饥饿;持有锁的线程,如果执行的时间过长,也可能导致饥饿问题。
解决方法:
- 保证资源充足
- 公平的分配资源
- 防止持有锁的线程长时间执行。
性能
多线程是好但是要切记勿装逼强行使用,装逼必被打。我们使用多线程的出发点是要了解,是为了提供系统的性能,充分利用系统资源。但是引入多线程后会引入额外的开销。
衡量应用程序性能一般:服务时间、延迟时间、吞吐量、可伸缩性,深入了解性能优化。
做应用的时候:
- 先保证程序的正确性跟健壮性,确实达不到性能要求再想如何提速。
- 一定要以测试为基准。
- 一个程序中串行的部分永远是有的.
- 装逼利器:阿姆达尔定律 S=1/(1-a+a/n)
系统中某一部件因为采用更快的实现后,整个系统性能的提高与该部分的使用频率或者在总运行时间中比例有关。直观地,你把一个部件提升了很多,但是这个部件却不经常使用,因此这种提高看上去是提高其实并没有。所以Amdahl定律认为我们除了需要关注部件的加速比,还要关注该部件的使用频率/情况。
影响因素
- 多线程的上下文切换,不是多线程一定好。比如只有一个核心,让你做语数外三门作业,如果你顺序做是可以的,这样不会涉及到任何作业场景的布置环境切换,而如果你非要同时做三门作业那么就会来回切换了,反正耗时!联想到多线程的上下文切换同样如此,CPU切换一个上下文就是几微妙哦!线程池的设置思想跟这个类似。
- 内存同步加锁等操作在编译代码后都有叠加指令存在的。
- 一些线程获得锁失败了还会进行阻塞式的等待。
常用的思路一般也是如下几点。
缩小锁的范围
能用方法块尽量不要锁函数
private Map<String,String> matchMap = new HashMap<>();
public synchronized boolean isMatch(String name,String regexp) { // 太大
String key = "user."+name;
String job = matchMap.get(key);
if(job == null) {
return false;
}else {
return Pattern.matches(regexp, job);//很耗费时间
}
}
public boolean isMatchReduce(String name,String regexp) {
String key = "user."+name;
String job ;
synchronized(this) { // 细致化 更好
job = matchMap.get(key);
}
if(job == null) {
return false;
}else {
return Pattern.matches(regexp, job);
}
}
锁粗化
synchronized特性:可重入,独享,悲观锁
锁优化:锁消除是发生在编译器级别的一种锁优化方式,是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除(开启锁消除的参数:-xx:+DoEscapeAnalysis -XX:+EliminateLocks)
锁粗化是指有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
减少锁的粒度跟锁分段
使用锁的时候,锁保护对象锁是多个的,多个之间其实是独立变化的 ,那就用多个锁来分别保护。但是要注意发生死锁。
public class FinenessLock {
public final Set<String> users = new HashSet<String>();
public final Set<String> queries = new HashSet<String>();
public void addUser(String u) {
synchronized (users) { // 注意锁的谁
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) { // 注意锁的谁
queries.add(q);
}
}
}
比如我们的ConcurrentHashMap
用的分段锁来提速。
替换独占锁
- 读写锁的使用,读频繁写很少。
- 用CAS操作来替换重型锁。
- 尽量用JDK自带的并发容器。
ending:推荐个阿里编写的深入浅出Java多线程,公众号回复【深入浅出多线程】即可获取。