Java开发容易忽视的错误

1:对象相等判断

和 equals 的区别是什么
== : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象。(基本数据类型 == 比较的是值,引用数据类型 == 比较的是内存地址)

equals() : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况:

情况1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。

情况2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来两个对象的内容相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。

public class test1 {
    public static void main(String[] args) {
        String a = new String("ab"); // a 为一个引用
        String b = new String("ab"); // b为另一个引用,对象的内容一样
        //======注意以下的aa和bb比较的都是字符串常量,所以他们比较会返回true
        String aa = "ab"; // 放在常量池中
        String bb = "ab"; // 从常量池中查找
        if (aa == bb) // true
            System.out.println("aa==bb");
        if (a == b) // false,非同一对象
            System.out.println("a==b");
        if (a.equals(b)) // true
            System.out.println("aEQb");
        if (42 == 42.0) { // true
            System.out.println("true");
        }
    }
}

2:如果整型字面量的值在-128到127之间,那么自动装箱时不会new新的Integer对象,而是直接引用常量池中的Integer对象,超过范围 a1==b1的结果是false

public static void main(String[] args) {
    Integer a = new Integer(3);
    Integer b = 3;  // 将3自动装箱成Integer类型
    int c = 3;
    System.out.println(a == b); // false 两个引用没有引用同一对象
    System.out.println(a == c); // true a自动拆箱成int类型再和c比较
    System.out.println(b == c); // true

    Integer a1 = 128;
    Integer b1 = 128;
    System.out.println(a1 == b1); // false

    Integer a2 = 127;
    Integer b2 = 127;
    System.out.println(a2 == b2); // true
}

3.Lombok 的 @Data 注解会帮我们实现 equals 和 hashcode 方法,但是有继承关系时是不会考虑父类的属性,而认为这两个员工是同一人,所以我们必须加上@EqualsAndHashCode(callSuper = true)

@Slf4j
class ff{
    public static void main(String[] args) {
        Employee employee1 = new Employee("zhuye","001", "bkjk.com");
        Employee employee2 = new Employee("Joseph","002", "bkjk.com");
        log.info("employee1.equals(employee2) ? {}", employee1.equals(employee2));
    }
}
@Data
class Person {
    private String name;
    private String identity;
    public Person(String name, String identity) {
        this.name = name;
        this.identity = identity;
    }
}
@Data
@EqualsAndHashCode(callSuper = true)
class Employee extends Person {
    private String company;
    public Employee(String name, String identity, String company) {
        super(name, identity);
        this.company = company;
    }
}

说明:以上代码如果不加@EqualsAndHashCode(callSuper = true),main函数返回的结果将会是true

4:Double不能用于精确计算,如涉及道金额计算,请使用BigDecimal

@Slf4j
class ff {
    public static void main(String[] args) {
        System.out.println(0.1+0.2);
        System.out.println(1.0-0.8);
        System.out.println(4.015*100);
        System.out.println(123.3/100);
        double amount1 = 2.15;
        double amount2 = 1.10;
        if (amount1 - amount2 == 1.05)
            System.out.println("OK");
    }
}

输出结果为

0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999

5:再使用BigDecimal时也有坑,请使用正确的构造函数,结果请自行对比以下结果

System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
//===========================注意以下使用string作为参数==================================
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));

输出结果

0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875
//===========================注意以下使用string作为参数==================================
0.3
0.2
401.500
1.233

使用BigDecimal.valueOf()可以将Double转换为BigDecimal需要的格式

6:如果要判断BigDecimal是否相等,请使用compareTo方法而不要使用equals,因为equals会同时考虑value和scale

new BigDecimal("1.0").equals(new BigDecimal("1")) = false

7:HashSet与TreeSet的contains实现原理不一样,TreSet不适用hashCode和equals比较元素,而是使用compareTo比较元素

Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false
=======================================================================
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1")));//返回true

把 BigDecimal 存入 HashSet 或 HashMap 前,先使用
stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的
BigDecimal,scale 也是一致的:

Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZero

8:解决数值溢出问题的方案(比如 Long.MAX_VALUE + 1会输出一个负数):

(1):使用Math类的addExact / subtractExact等xxExact方法进行数值运算,这些方法可以再数值溢出时主动抛出异常
(2):使用BigDecimal的add方法

9:不能直接使用Arrays.asList来转换基本类型数组,应该使用包装类。(如你头比较铁一定要用基本数据类型,可以使用stream)。使用Arrays.asList返回的List不支持增删操作,我们可以使用list的构造函数重新重新构造

		int[] a = {1,2,3,4};
        List<int[]> ints = Arrays.asList(a); //注意这里返回的时int[]

        Integer[] b = {1, 2, 3};
        List<Integer> integers = Arrays.asList(b); //这个返回的时Integer

        //integers.add(8);     //这一步会报错,因为转换来的list长度是不可变的,所以不能添加元素
		
        List<Integer> c = new ArrayList<>(integers);	//使用List的构造函数后就可以添加元素了
        c.add(6);

10:subList方法的返回值,只是ArrayList的一个映像而已。也就是说,当我们使用子集合subList进行元素的修改操作时,会影响原有的list集合。

List<String> arrayList = new ArrayList<String>();		
		arrayList.add("a");
		arrayList.add("b");
		arrayList.add("c");
		List<String> arrayList_subList = arrayList.subList(0, 2);
		arrayList.remove(0);
		arrayList.add("d");
		System.out.println(arrayList_subList.size());

以上代码将会抛出java.util.ConcurrentModificationException。

11:烦人的null

	(1):Mysql中sum函数没统计到任何记录时,会返回null而不是0,可以使用IFNULL
	函数把null转换为0
	(2):Mysql中count字段不统计null值,count(*)才是统计所有记录数量的正确方式
	(3):mysql中 = null并不是判断条件而是赋值,对null进行判断只能使用is null或者is not null	

12:异常处理:

(1):可以将异常抛到controller,配置统一异常处理
@ControllerAdvice("com.bonade")
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(ClientTokenException.class)
    public BaseResponse clientTokenExceptionHandler(HttpServletResponse response, 		ClientTokenException ex) {
        response.setStatus(403);
        logger.error(ex.getMessage(),ex);
        return new BaseResponse(ex.getStatus(), ex.getMessage());
    }
 }   

(2)避免捕获异常后直接生吞,也就是不做任何处理,我们应该将异常写入日志,并且保留原始异常信息

try{
}catch (Exception e) {
	throw new RuntimeException("系统忙请稍后再试", e);
}

(3):小心finally中的异常,虽然 try 中的逻辑出现了异常,但却被 finally中的异常覆盖了

错误示范:

try {
	log.info("try");
	//异常丢失
	throw new RuntimeException("try");
} finally {
	log.info("finally");
	throw new RuntimeException("finally");
}

正确示范:

try {
	log.info("try");
	throw new RuntimeException("try");
} finally {
	log.info("finally");
	try {
		throw new RuntimeException("finally");
	} catch (Exception ex) {
		log.error("finally", ex);
	}
}

13:对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:

方式一:一UTC保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳,或java中的Date类就是用这种方式,也是推荐的方式
方式二:以字面量保存,比如年/月/日 时:分:秒,一定要同时保存时区信息。只有有了时区信息,我们才能知道这个字面量时间真正的时间起点,否则它只是一个给人看的时间表示,只是当前时区有意义。Calendar是有时区概念的,所以我们通过不同的时区初始化Calendar,得到了不同的时间

第一类是,对于同一个时间表示,比如 2020-01-02 22:00:00,不同时区的人转换成 Date会得到不同的时间(时间戳):

String stringDate = "2020-01-02 22:00:00";
SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//默认时区解析时间表示
Date date1 = inputFormat.parse(stringDate);
System.out.println(date1 + ":" + date1.getTime());
//纽约时区解析时间表示
inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
Date date2 = inputFormat.parse(stringDate);
System.out.println(date2 + ":" + date2.getTime());

输出结果:

Thu Jan 02 22:00:00 CST 2020:1577973600000
Fri Jan 03 11:00:00 CST 2020:1578020400000

这正是 UTC 的意义,并不是时间错乱。对于同一个本地时间的表示,不同时区的人解析得
到的 UTC 时间一定是不同的,反过来不同的本地时间可能对应同一个 UTC。

第二类问题是,格式化后出现的错乱,即同一个 Date,在不同的时区下格式化得到不同的
时间表示。比如,在我的当前时区和纽约时区格式化 2020-01-02 22:00:00

String stringDate = "2020-01-02 22:00:00";
SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//同一Date
Date date = inputFormat.parse(stringDate);
//默认时区格式化输出:
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date);
//纽约时区格式化输出
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
System.out.println(new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss Z]").format(date);

所以,要正确处理时区,在于存进去和读出来两方面:存的时候,需要使用正确的当前时区
来保存,这样 UTC 时间才会正确;读的时候,也只有正确设置本地时区,才能把 UTC 时
间转换为正确的当地时间
Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime和DateTimeFormatter,处理时区问题更简单清晰。
要正确处理国际化时间问题,我推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错

日期时间格式化和解析
每到年底,就有很多开发同学踩时间格式化的坑,比如“这明明是一个 2019 年的日期,怎
么使用 SimpleDateFormat 格式化后就提前跨年了”。我们来重现一个这个问题。
初始化一个 Calendar,设置日期时间为 2019 年 12 月 29 日,使用大写的 YYYY 来初始
化 SimpleDateFormat:

Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
System.out.println("defaultLocale:" + Locale.getDefault());
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 29,0,0,0);
SimpleDateFormat YYYY = new SimpleDateFormat("YYYY-MM-dd");
System.out.println("格式化: " + YYYY.format(calendar.getTime()));
System.out.println("weekYear:" + calendar.getWeekYear());
System.out.println("firstDayOfWeek:" + calendar.getFirstDayOfWeek());
System.out.println("minimalDaysInFirstWeek:" + calendar.getMinimalDaysInFirstWe

得到的输出却是 2020 年 12 月 29 日:

defaultLocale:zh_CN
格式化: 2020-12-29
weekYear:2020
firstDayOfWeek:1
minimalDaysInFirstWeek:1

出现这个问题的原因在于,这位同学混淆了 SimpleDateFormat 的各种格式化模式。JDK的文档中有说明:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。一年第一周的判断方式是,从 getFirstDayOfWeek() 开始,完整的 7 天,并且包含那一年至少 getMinimalDaysInFirstWeek() 天。这个计算方式和区域相关,对于当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年

这个案例告诉我们,没有特殊需求,针对年份的日期格式化,应该一律使用 “y” 而非
“Y”。

除了格式化表达式容易踩坑外,SimpleDateFormat 还有两个著名的坑。第一个坑是,定义的 static 的 SimpleDateFormat 可能会出现线程安全问题。比如像这样,使用一个 100 线程的线程池,循环 20 次把时间格式化任务提交到线程池处理,每个任务中又循环 10 次解析 2020-01-01 11:12:13 这样一个时间表示:

ExecutorService threadPool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 20; i++) {
	//提交20个并发解析时间的任务到线程池,模拟并发环境
	threadPool.execute(() -> {
		for (int j = 0; j < 10; j++) {
			try {
				System.out.println(simpleDateFormat.parse("2020-01-01 11:12:13
			} catch (ParseException e) {
				e.printStackTrace();
			}
		}
	});
}
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);

运行程序后大量报错,且没有报错的输出结果也不正常,比如 2020 年解析成了 1212 年:
在这里插入图片描述
第二个坑是,当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽
容,还是能得到结果。比如,我们期望使用 yyyyMM 来解析 20160901 字符串:

String dateString = "20160901";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
System.out.println("result:" + dateFormat.parse(dateString));

居然输出了 2091 年 1 月 1 日,原因是把 0901 当成了月份,相当于 75 年:

在这里插入图片描述

对于 SimpleDateFormat 的这三个坑,我们使用 Java 8 中的 DateTimeFormatter 就可以避过去。首先,使用 DateTimeFormatterBuilder 来定义格式化字符串,不用去记忆使用大写的 Y 还是小写的 Y,大写的 M 还是小写的 m:

使用 Java 8 操作和计算日期时间虽然方便,但计算两个日期差时可能会踩坑:Java 8 中有一个专门的类 Period 定义了日期间隔,通过 Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。

14:事务中容易出现的错误
(1):@Transactional生效原则1,除非特殊配置(比如使用一个AspectJ静态织入实现AOP),否则只有定义再public方法上的@Transactional才能生效。。原因是,Spring 默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到, Spring 自然也无法动态增强事务处理逻辑。
(2):必须通过代理过的类从外部调用目标方法才能生效

如下方法事务不生效:

class UserService {
	public int createUserWrong2(String name) {    
	try {        
		this.createUserPublic(new UserEntity(name));    
	} catch (Exception ex) {        
		log.error("create user failed because {}", ex.getMessage());    
	}  
	return userRepository.findByName(name).size(); 
}
	//标记了@Transactional的public方法 @Transactional
	public void createUserPublic(UserEntity entity) {    
		userRepository.save(entity);    
		if (entity.getName().contains("test")) {       
			throw new RuntimeException("invalid username!");
		 }
	}	 
}


将this改为注入的self事务才能生效,this 指针代表对象自己,Spring 不可能注入 this,所以通过 this 访问方法必然不是代 理

class UserService {
	@Autowired
	private UserService self;
	public int createUserWrong2(String name) {    
		try {        
			self.createUserPublic(new UserEntity(name));    
		} catch (Exception ex) {        
			log.error("create user failed because {}", ex.getMessage());    
		}  
		return userRepository.findByName(name).size(); 
	}
	//标记了@Transactional的public方法 @Transactional
	public void createUserPublic(UserEntity entity) {    
		userRepository.save(entity);    
		if (entity.getName().contains("test")) {       
			throw new RuntimeException("invalid username!");
		 }
	}	 
}

虽然在 UserService 内部注入自己调用自己的 createUserPublic 可以正确实现事务,但更 合理的实现方式是,让 Controller 直接调用之前定义的 UserService 的 createUserPublic 方法,因为注入自己调用自己很奇怪,也不符合分层实现的规范

14.事务即便生效也不一定能回滚

通过 AOP 实现事务处理可以理解为,使用 try…catch…来包裹标记了 @Transactional 注 解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务 回滚,没有异常则直接提交事务

这里的“一定条件”,主要包括两点。
第一,只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚
第二,默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring 才会回滚事务

重新实现一下 UserService 中的注册用户操作:
Controller 中的实现,仅仅是调用 UserService 的 createUserWrong1 和 createUserWrong2 方法,这里就贴出实现了。这 2 个方法的实现和调用,虽然完全避开
在 createUserWrong1 方法中会抛出一个 RuntimeException,但由于方法内 catch 了 所有异常,异常无法从方法传播出去,事务自然无法回滚。
在 createUserWrong2 方法中,注册用户的同时会有一次 otherTask 文件读取操作, 如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但 因为 otherTask 方法抛出的是受检异常,createUserWrong2 传播出去的也是受检异 常,事务同样不会回滚。

@Service 
@Slf4j 
public class UserService {
    @Autowired
    private UserRepository userRepository;        
    //异常无法传播出方法,导致事务无法回滚    
    @Transactional    
    public void createUserWrong1(String name) {
            try {
                        userRepository.save(new UserEntity(name));            
                        throw new RuntimeException("error");        
             } catch (Exception ex) {
                         log.error("create user failed", ex);
               }
     }
    //即使出了受检异常也无法让事务回滚
     @Transactional    
     public void createUserWrong2(String name) throws IOException { 
           userRepository.save(new UserEntity(name));       
           otherTask();    
     }
    //因为文件不存在,一定会抛出一个IOException    
    private void otherTask() throws IOException {   
         	Files.readAllLines(Paths.get("file-that-not-exist"));    
    } 
}

现在,我们来看下修复方式,以及如何通过日志来验证是否修复成功。针对这 2 种情况, 对应的修复方法如下。
第一,如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回 滚状态

@Transactional 
public void createUserRight1(String name) {    
	try {        
		userRepository.save(new UserEntity(name));        
		throw new RuntimeException("error");    
	} catch (Exception ex) {        
		log.error("create user failed", ex);        				 
		TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();    
	} 
}

运行后可以在日志中看到 Rolling back 字样,确认事务回滚了。同时,我们还注意 到“Transactional code has requested rollback”的提示,表明手动请求回滚:

第二,在注解中声明,期望遇到所有的 Exception 都回滚事务(来突破默认不回滚受检异 常的限制):

@Transactional(rollbackFor = Exception.class) 
public void createUserRight2(String name) throws IOException {
    userRepository.save(new UserEntity(name));
    otherTask(); 
}

在这个例子中,我们展现的是一个复杂的业务逻辑,其中有数据库操作、IO 操作,在 IO 操作出现问题时希望让数据库事务也回滚,以确保逻辑的一致性。在有些业务逻辑中,可能 会包含多次数据库操作,我们不一定希望将两次操作作为一个事务来处理,这时候就需要仔 细考虑事务传播的配置了,否则也可能踩坑。

请确认事务传播配置是否符合自己的业务逻辑
有这么一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的 子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影 响主流程,即不影响主用户的注册。
这时候我们需要处理两步:
(1):使用try{}catch{}包裹子方法,不让异常抛出到主方法导致主方法回滚
(2):修改子方法的事务传播策略,加上注解 propagation = Propagation.REQUIRES_NEW 来设置 REQUIRES_NEW 方式的事务传播策略,也就是执 行到这个方法时需要开启新的事务,并挂起当前事务:

@Transactional 
public void createUserRight(UserEntity entity) {
    createMainUser(entity);   
     try{        	
     	subUserService.createSubUserWithExceptionRight(entity);
    } catch (Exception ex) {
            // 捕获异常,防止主方法回滚       
             log.error("create sub user error:{}", ex.getMessage()); 
     } 
}

@Transactional(propagation = Propagation.REQUIRES_NEW) 
public void createSubUserWithExceptionRight(UserEntity entity) { 
   	log.info("createSubUserWithExceptionRight start");    
   	userRepository.save(entity);    
   	throw new RuntimeException("invalid status");
}

15:序列化

第一,要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正
确处理序列化后的数据就要使用相同的反序列化算法。
第二,Jackson 有大量的序列化和反序列化特性,可以用来微调序列化和反序列化的细
节。需要注意的是,如果自定义 ObjectMapper 的 Bean,小心不要和 Spring Boot 自动
配置的 Bean 冲突。
第三,在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序
列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化。
第四,对于反序列化默认情况下,框架调用的是无参构造方法,如果要调用自定义的有参构
造方法,那么需要告知框架如何调用。更合理的方式是,对于需要序列化的 POJO 考虑尽
量不要自定义构造方法。
第五,枚举不建议定义在 DTO 中跨服务传输,因为会有版本问题,并且涉及序列化反序列
化时会很复杂,容易出错。因此,我只建议在程序内部使用枚举。
最后还有一点需要注意,如果需要跨平台使用序列化的数据,那么除了两端使用的算法要一
致外,还可能会遇到不同语言对数据类型的兼容问题。这,也是经常踩坑的一个地方。如果
你有相关需求,可以多做实验、多测试。

//=TODO======

文章内容来源:https://time.geekbang.org/column/intro/100047701
原文作者:朱晔老师
参考文章:《Java业务开发常见错误100例》
本文章为《Java业务开发常见错误100例》读后感,怕忘记所以简单的做了个笔记,要想了解更多易错知识,请阅读原版教材

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值