当前位置: 首页 > 科技观察

说说保证线程安全的十一个技巧_0

时间:2023-03-18 22:57:52 科技观察

前言对于从事后端开发的同学来说,线程安全是我们每天都需要考虑的问题。一般来说,线程安全问题主要是在多线程环境下,不同线程同时读写公共资源(关键资源)导致的数据异常。例如:变量a=0,线程1给这个变量+1,线程2也给这个变量+1。这时候线程3获取到的a的值可能不是2,而是1,线程3不会获取错数据吧?线程安全问题会直接导致数据异常,进而影响业务功能的正常使用,所以这个问题还是很严重的。那么,如何解决线程安全问题呢?今天和大家聊聊,保证线程安全的11个技巧,希望对大家有所帮助。1.无状态我们都知道,只有当多个线程访问公共资源时,才可能出现数据安全问题,那么如果我们没有公共资源,是不是就没有这个问题了?例如:publicclassNoStatusService{publicvoidadd(Stringstatus){System.out.println("addstatus:"+status);}publicvoidupdate(Stringstatus){System.out.println("更新状态:"+status);}}在这个例子中,NoStatusService没有定义公共资源,换句话说它是无状态的。在这种情况下,NoStatusService类必须是线程安全的。2.不可变如果多线程访问的公共资源是不可变的,就不会有数据安全问题。例如:publicclassNoChangeService{publicstaticfinalStringDEFAULT_NAME="abc";publicvoidadd(Stringstatus){System.out.println(DEFAULT_NAME);}}DEFAULT_NAME被定义为一个staticfinal常量,在多线程环境下不会被修改,所以在这种情况下,不会有线程安全问题。3、没有修改权限有时候,我们定义了一个公共资源,但是这个资源只暴露了读取的权限,没有暴露修改的权限,也是线程安全的。例如:publicclassSafePublishService{privateStringname;publicStringgetName(){返回名称;}publicvoidadd(Stringstatus){System.out.println("添加状态:"+status);}}在这个例子中,没有修改name字段的入口对外暴露,所以不存在线程安全问题。4.Synchronized使用了JDK提供的同步机制,使用方式也有很多,分为:同步方法和同步代码块。我们优先使用synchronized代码块,因为synchronized方法的粒度是整个方法,作用范围太大,相对来说对代码性能消耗更大。实际上,每个对象内部都有一把锁,只有抢到锁的线程才能进入相应的代码块,执行相应的代码。当代码块执行时,底层JVM会自动释放锁。例如:publicclassSyncService{privateintage=1;私有对象对象=新对象();//同步方法publicsynchronizedvoidadd(inti){age=age+i;System.out.println("年龄:"+年龄);}publicvoidupdate(inti){//同步代码块,对象锁synchronized(object){age=age+i;System.out.println("年龄:"+年龄);}}publicvoidupdate(inti){//同步代码块,类锁synchronized(SyncService.class){age=age+i;System.out.println("年龄:"+年龄);}}}五、Lock使用synchronized除了通过关键字实现同步功能,JDK还提供了Lock接口,这是一种显示锁的方式。通常我们会用到Lock接口的实现类:ReentrantLock,它包括:公平锁、非公平锁、重入锁、读写锁等更强大的功能。例如:publicclassLockService{privateReentrantLockreentrantLock=newReentrantLock();公共年龄=1;publicvoidadd(inti){try{reentrantLock.lock();年龄=年龄+我;System.out.println("年龄:"+age);}最后{reentrantLock.unlock();但是如果使用ReentrantLock,也会带来一个小问题:需要在finally代码块中手动释放锁。不过说实话,使用Lock来显示锁解决了线程安全问题,也为开发者提供了更大的灵活性。6.如果分布式锁是在单机的情况下,使用synchronized和Lock保证线程安全是没有问题的。但是如果在分布式环境下,即一个应用部署了多个节点,每个节点都可以使用synchronized和Lock来保证线程安全,但是不同节点之间不能保证线程安全。这就需要使用:分布式锁。分布式锁有很多种,比如:数据库分布式锁、zookeeper分布式锁、redis分布式锁等,其中我个人比较推荐使用redis分布式锁,相对效率更高一些。使用redis分布式锁的伪代码如下:try{Stringresult=jedis.set(lockKey,requestId,"NX","PX",expireTime);如果(“确定”。等于(结果)){返回真;}returnfalse;}finally{unlock(lockKey);}也需要在finally代码块中释放锁。七、volatile有时候,我们有这样一个需求:如果多个线程中有一个线程将某个switch的状态设置为false,则整个函数停止。经过简单的需求分析,发现只需要多线程之间的可见性,不需要原子性。如果一个线程修改了状态,所有其他线程都可以获得最新的状态值。这样的分析很容易搞定,使用volatile可以很快满足需求。例如:@ServicepublicCanalService{privatevolatilebooleanrunning=false;私有线程线程;@Autowired私有CanalConnectorcanalConnector;publicvoidhandle(){//connectcanalwhile(running){//业务处理}}publicvoidstart(){thread=newThread(this::handle,"name");运行=真;thread.start();}publicvoidstop(){if(!running){返回;}运行=假;}}需要特别注意的地方是:volatile不能用在计数、统计等业务场景中。因为volatile不能保证操作的原子性,可能会出现数据异常。8.ThreadLocal除了上述解决方案,JDK还提供了另一种用空间换取时间的新思路:ThreadLocal。当然ThreadLocal并不能完全替代锁,尤其是在一些秒杀更新库存的时候,必须要用到锁。ThreadLocal的核心思想是:共享变量在每个线程中都有一个副本,每个线程操作自己的副本,对其他线程没有影响。温馨提示:我们平时使用ThreadLocal的时候,在使用完之后,一定要记得在finally代码块中调用它的remove方法来清除数据,否则可能会出现内存泄漏。例如:publicclassThreadLocalService{privateThreadLocalthreadLocal=newThreadLocal<>();publicvoidadd(inti){Integerinteger=threadLocal.get();threadLocal.set(整数==空?0:整数+我);}}九、线程安全的集合有时候,我们需要使用的公共资源都放在某个集合中,比如:ArrayList、HashMap、HashSet等,如果在多线程环境下,线程向这些集合写入数据,而其他线程从集合中读取数据,可能会出现线程安全问题。为了解决集合的线程安全问题,JDK专门为我们提供了可以保证线程安全的集合。例如:CopyOnWriteArrayList、ConcurrentHashMap、CopyOnWriteArraySet、ArrayBlockingQueue等。例如:publicclassHashMapTest{privatestaticConcurrentHashMaphashMap=newConcurrentHashMap<>();publicstaticvoidmain(String[]args){newThread(newRunnable(){@Overridepublicvoidrun(){hashMap.put("key1","value1");}}).start();newThread(newRunnable(){@Overridepublicvoidrun(){hashMap.put("key2","value2");}}).start();尝试{Thread.sleep(50);}catch(InterruptedExceptione){e.printStackTrace();}System.out.println(hashMap);}}在JDK底层,或者在spring框架中,有很多场景使用ConcurrentHashMap来保存和加载配置参数。比较出名的是在spring的refresh方法中,会读取配置文件,将配置缓存在很多ConcurrentHashMap中。10、CASJDK除了使用锁机制解决多线程情况下的数据安全问题外,还提供了CAS机制。这种机制利用了CPU中比较和交换指令的原子性,通过JDK中的Unsafe类实现。CAS包含四个值:olddata,expecteddata,newdata和address,比较旧数据和期望数据,如果相同,则将旧数据更改为新数据。如果不相同,则当前线程继续自旋,直到成功。但是使用CAS保证线程安全可能会导致ABA问题,需要使用AtomicStampedReference增加版本号来解决。其实在实际工作中很少直接使用Unsafe类,一般都是使用atomic包下的类。公共类AtomicService{privateAtomicIntegeratomicInteger=newAtomicInteger();publicintadd(inti){returnatomicInteger.getAndAdd(i);}}11.数据隔离有时候,我们在操作采集数据的时候,可以使用数据隔离,来保证线程安全。例如:publicclassThreadPoolTest{publicstaticvoidmain(String[]args){ExecutorServicethreadPool=newThreadPoolExecutor(8,//corePoolSize线程池中核心线程数为10,//maximumPoolSize最大线程数线程池为60,//线程池中线程的最大空闲时间,超过这个时间的空闲线程将被回收TimeUnit.SECONDS,//时间单位newArrayBlockingQueue(500),//入队newThreadPoolExecutor.CallerRunsPolicy());//拒绝策略ListuserList=Lists.newArrayList(newUser(1L,"苏三",18,"成都"),newUser(2L,"苏三说技术",20,"四川"),newUser(3L,"科技",25,"云南"));for(Useruser:userList){threadPool.submit(newWork(user));}尝试{Thread.sleep(100);}catch(InterruptedExceptione){e.printStackTrace();}系统.out.println(userList);}staticclassWorkimplementsRunnable{privateUser用户;公共工作(用户用户){this.user=user;}@Overridepublicvoidrun(){使用r.setName(user.getName()+"测试");}}}在本例中,线程池用于处理用户信息。每个用户只被线??程池中的一个线程处理,不存在多个线程同时处理一个。用户的情况。所以这种人为的数据隔离机制也能保证线程安全。数据隔离还有另外一种场景:Kafka生产者向同一个分区发送相同顺序的消息。每个部分部署一个消费者。在Kafka消费者中,使用单线程接收消息和进行业务处理。在这种场景下,整体上不同分区使用多线程处理数据,而同一个分区使用单线程处理,所以也可以解决线程安全问题。