3

聊聊保证线程安全的十一个小技巧

 2 years ago
source link: https://developer.51cto.com/article/710903.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
e319651659130468b8652126d6559e1b473f8f.png

对于从事后端开发的同学来说,线程安全问题是我们每天都需要考虑的问题。

线程安全问题通俗的讲:主要是在多线程的环境下,不同线程同时读和写公共资源(临界资源),导致的数据异常问题。

比如:变量a=0,线程1给该变量+1,线程2也给该变量+1。此时,线程3获取a的值有可能不是2,而是1。线程3这不就获取了错误的数据?

线程安全问题会直接导致数据异常,从而影响业务功能的正常使用,所以这个问题还是非常严重的。

那么,如何解决线程安全问题呢?

今天跟大家一起聊聊,保证线程安全的11个小技巧,希望对你有所帮助。

图片

一、 无状态

我们都知道只有多个线程访问公共资源的时候,才可能出现数据安全问题,那么如果我们没有公共资源,是不是就没有这个问题呢?

public class NoStatusService {
    public void add(String status) {
        System.out.println("add status:" + status);
    }
    public void update(String status) {
        System.out.println("update status:" + status);
    }
}

这个例子中NoStatusService没有定义公共资源,换句话说是无状态的。

这种场景中,NoStatusService类肯定是线程安全的。

二、不可变

如果多个线程访问的公共资源是不可变的,也不会出现数据的安全性问题。

public class NoChangeService {
    public static final String DEFAULT_NAME = "abc";
    public void add(String status) {
        System.out.println(DEFAULT_NAME);
    }
}

DEFAULT_NAME被定义成了static final的常量,在多线程中环境中不会被修改,所以这种情况,也不会出现线程安全问题。

三、无修改权限

有时候,我们定义了公共资源,但是该资源只暴露了读取的权限,没有暴露修改的权限,这样也是线程安全的。

public class SafePublishService {
    private String name;
    public String getName() {
        return name;
    }
    public void add(String status) {
        System.out.println("add status:" + status);
    }
}

这个例子中,没有对外暴露修改name字段的入口,所以不存在线程安全问题。

四、synchronized

使用JDK​内部提供的同步机制​,这也是使用比较多的手段,分为:同步方法​ 和 同步代码块。

我们优先使用同步代码块,因为同步方法的粒度是整个方法,范围太大,相对来说,更消耗代码的性能。

其实,每个对象内部都有一把锁​,只有抢到那把锁的线程,才被允许进入对应的代码块执行相应的代码。

当代码块执行完之后,JVM底层会自动释放那把锁。

public class SyncService {
    private int age = 1;
    private Object object = new Object();
    //同步方法
    public synchronized void add(int i) {
        age = age + i;        
        System.out.println("age:" + age);
    }   
    public void update(int i) {
        //同步代码块,对象锁
        synchronized (object) {
            age = age + i;                     
            System.out.println("age:" + age);
        }    
     }   
     public void update(int i) {
        //同步代码块,类锁
        synchronized (SyncService.class) {
            age = age + i;                     
            System.out.println("age:" + age);
        }    
     }
}

五、Lock

除了使用synchronized​关键字实现同步功能之外,JDK还提供了Lock接口,这种显示锁的方式。

通常我们会使用Lock​接口的实现类:ReentrantLock​,它包含了:公平锁、非公平锁、可重入锁、读写锁 等更多更强大的功能。

public class LockService {
    private ReentrantLock reentrantLock = new ReentrantLock();
    public int age = 1;  
    public void add(int i) {
        try {
            reentrantLock.lock();
            age = age + i;           
            System.out.println("age:" + age);
        } finally {
            reentrantLock.unlock();        
        }    
   }
}

但如果使用ReentrantLock,它也带来了有个小问题就是:需要在finally代码块中手动释放锁。

不过说句实话,在使用Lock显示锁的方式,解决线程安全问题,给开发人员提供了更多的灵活性。

六、分布式锁

如果是在单机的情况下,使用synchronized和Lock保证线程安全是没有问题的。

但如果在分布式的环境中,即某个应用如果部署了多个节点,每一个节点使用可以synchronized​和Lock保证线程安全,但不同的节点之间,没法保证线程安全。

这就需要使用:分布式锁了。

分布式锁有很多种,比如:数据库分布式锁,zookeeper分布式锁,redis分布式锁等。

其中我个人更推荐使用redis分布式锁,其效率相对来说更高一些。

使用redis分布式锁的伪代码如下:

try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}

同样需要在finally代码块中释放锁。

七、volatile

有时候,我们有这样的需求:如果在多个线程中,有任意一个线程,把某个开关的状态设置为false,则整个功能停止。

简单的需求分析之后发现:只要求多个线程间的可见性​,不要求原子性。

如果一个线程修改了状态,其他的所有线程都能获取到最新的状态值。

这样一分析这就好办了,使用volatile就能快速满足需求。

@Service
public CanalService {
    private volatile boolean running = false;
    private Thread thread;
    @Autowired
    private CanalConnector canalConnector;
    
    public void handle() {
        //连接canal
        while(running) {
           //业务处理
        }
    }  
    public void start() {
       thread = new Thread(this::handle, "name");
       running = true;
       thread.start();
    }   
    public void stop() {
       if(!running) {
          return;
       }
       running = false;
    }
}

需要特别注意的地方是:volatile​不能用于计数和统计等业务场景。因为volatile不能保证操作的原子性,可能会导致数据异常。

八、ThreadLocal

除了上面几种解决思路之外,JDK还提供了另外一种用空间换时间​的新思路:ThreadLocal。

当然ThreadLocal并不能完全取代锁,特别是在一些秒杀更新库存中,必须使用锁。

ThreadLocal的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。

温馨提醒一下:我们平常在使用ThreadLocal时,如果使用完之后,一定要记得在finally​代码块中,调用它的remove​方法清空数据,不然可能会出现内存泄露问题。

public class ThreadLocalService {
    private ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    public void add(int i) {
        Integer integer = threadLocal.get();
        threadLocal.set(integer == null ? 0 : integer + i);
    }
}

九、线程安全集合

有时候,我们需要使用的公共资源放在某个集合当中,比如:ArrayList、HashMap、HashSet等。

如果在多线程环境中,有线程往这些集合中写数据,另外的线程从集合中读数据,就可能会出现线程安全问题。

为了解决集合的线程安全问题,JDK专门给我们提供了能够保证线程安全的集合。

比如:CopyOnWriteArrayList、ConcurrentHashMap、CopyOnWriteArraySet、ArrayBlockingQueue等等。

public class HashMapTest {
    private static ConcurrentHashMap<String, Object> hashMap = new ConcurrentHashMap<>();
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                hashMap.put("key1", "value1");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                hashMap.put("key2", "value2");
            }
        }).start();
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(hashMap);
    }
}

在JDK底层,或者spring框架当中,使用ConcurrentHashMap保存加载配置参数的场景非常多。

比较出名的是spring的refresh方法中,会读取配置文件,把配置放到很多的ConcurrentHashMap缓存起来。

十、CAS

JDK除了使用锁的机制解决多线程情况下数据安全问题之外,还提供了CAS机制。

这种机制是使用CPU中比较和交换指令的原子性,JDK里面是通过Unsafe类实现的。

CAS内部包含了四个值:旧数据​、期望数据​、新数据​ 和 地址​,比较旧数据 和 期望的数据,如果一样的话,就把旧数据改成新数据。如果不一样的话,当前线程不断自旋,一直到成功为止。

不过,使用CAS保证线程安全,可能会出现ABA​问题,需要使用AtomicStampedReference增加版本号解决。

其实,实际工作中很少直接使用Unsafe​类的,一般用atomic包下面的类即可。

public class AtomicService {
    private AtomicInteger atomicInteger = new AtomicInteger();   
    public int add(int i) {
        return atomicInteger.getAndAdd(i);
    }
}

十一、数据隔离

有时候,我们在操作集合数据时,可以通过数据隔离,来保证线程安全。

public class ThreadPoolTest {
    public static void main(String[] args) {
      ExecutorService threadPool = new ThreadPoolExecutor(8, //corePoolSize线程池中核心线程数
      10, //maximumPoolSize 线程池中最大线程数
      60, //线程池中线程的最大空闲时间,超过这个时间空闲线程将被回收
      TimeUnit.SECONDS,//时间单位
      new ArrayBlockingQueue(500), //队列
      new ThreadPoolExecutor.CallerRunsPolicy()); //拒绝策略
      List<User> userList = Lists.newArrayList(
      new User(1L, "苏三", 18, "成都"),
      new User(2L, "苏三说技术", 20, "四川"),
      new User(3L, "技术", 25, "云南"));
      for (User user : userList) {
          threadPool.submit(new Work(user));
      }
      try {
          Thread.sleep(100);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
      System.out.println(userList);
  }
    static class Work implements Runnable {
        private User user;

        public Work(User user) {
            this.user = user;
        }
        @Override
        public void run() {
            user.setName(user.getName() + "测试");
        }
    }
}

这个例子中,使用线程池处理用户信息。

每个用户只被线程池​中的一个线程处理,不存在多个线程同时处理一个用户的情况。所以这种人为的数据隔离机制,也能保证线程安全。

数据隔离还有另外一种场景:kafka生产者把同一个订单的消息,发送到同一个partion中。每一个partion都部署一个消费者,在kafka消费者中,使用单线程接收消息,并且做业务处理。

这种场景下,从整体上看,不同的partion是用多线程处理数据的,但同一个partion则是用单线程处理的,所以也能解决线程安全问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK