杂文笔记

229 阅读11分钟

java中的位运算

image.png

运算运算符号示例
&与运算0110 &0101 =0100
|或运算0110 |0101 =0111
^异或运算0110 ^0101 =0011
~反码~0100 =11111111111111111111111111111011
<<左移011 << 2=01100
>>右移011 >> 1=001
>>>无符号右移011 >>> 1=001

&:按位与

操作规则:同为1则1,否则为0。仅当两个操作数都为1时,输出结果才为1,否则为0。

@Test
public void test() {
    int i = 0B100; // 十进制为4
    int j = 0B101; // 十进制为5

    // 二进制结果:100
    // 十进制结果:4
    System.out.println("二进制结果:" + Integer.toBinaryString(i & j));
    System.out.println("十进制结果:" + (i & j));
}

|:按位或

操作规则:同为0则0,否则为1。仅当两个操作数都为0时,输出的结果才为0。

@Test
public void test() {
    int i = 0B100; // 十进制为4
    int j = 0B101; // 十进制为5

    // 二进制结果:101
    // 十进制结果:5
    System.out.println("二进制结果:" + Integer.toBinaryString(i | j));
    System.out.println("十进制结果:" + (i | j));
}

~:按位非

操作规则:0为1,1为0。全部的0置为1,1置为0。

小贴士:请务必注意是全部的,别忽略了正数前面的那些0

@Test
public void test() {
    int i = 0B100; // 十进制为4

    // 二进制结果:11111111111111111111111111111011
    // 十进制结果:-5
    System.out.println("二进制结果:" + Integer.toBinaryString(~i));
    System.out.println("十进制结果:" + (~i));
}

^:按位异或

操作规则:相同为0,不同为1。操作数不同时(1遇上0,0遇上1)对应的输出结果才为1,否则为0。

@Test
public void test() {
    int i = 0B100; // 十进制为4
    int j = 0B101; // 十进制为5

    // 二进制结果:1
    // 十进制结果:1
    System.out.println("二进制结果:" + Integer.toBinaryString(i ^ j));
    System.out.println("十进制结果:" + (i ^ j));
}

<<:按位左移

操作规则:把一个数的全部位数都向左移动若干位。

@Test
public void test() {
    int i = 0B100; // 十进制为4

    // 二进制结果:100000
    // 十进制结果:32 = 4 * (2的3次方)
    System.out.println("二进制结果:" + Integer.toBinaryString(i << 3));
    System.out.println("十进制结果:" + (i << 3));
}

左移用得非常多,理解起来并不费劲。x左移N位,效果同十进制里直接乘以2的N次方就行了,但是需要注意值溢出的情况,使用时稍加注意。

>>:按位右移

操作规则:把一个数的全部位数都向右移动若干位。

@Test
public void test() {
    int i = 0B100; // 十进制为4

    // 二进制结果:10
    // 十进制结果:2
    System.out.println("二进制结果:" + Integer.toBinaryString(i >> 1));
    System.out.println("十进制结果:" + (i >> 1));
}
负数右移:
@Test
public void test() {
    int i = -0B100; // 十进制为-4

    // 二进制结果:11111111111111111111111111111110
    // 十进制结果:-2
    System.out.println("二进制结果:" + Integer.toBinaryString(i >> 1));
    System.out.println("十进制结果:" + (i >> 1));
}

右移用得也比较多,也比较理解:操作其实就是把二进制数右边的N位直接砍掉,然后正数右移高位补0,负数右移高位补1

>>>:无符号右移

注意:没有无符号左移,并没有<<<这个符号的

它和>>有符号右移的区别是:无论是正数还是负数,高位通通补0。所以说对于正数而言,没有区别;那么看看对于负数的表现:

@Test
public void test() {
    int i = -0B100; // 十进制为-4

    // 二进制结果:11111111111111111111111111111110(>>的结果)
	// 二进制结果:1111111111111111111111111111110(>>>的结果)
    // 十进制结果:2147483646
    System.out.println("二进制结果:" + Integer.toBinaryString(i >>> 1));
    System.out.println("十进制结果:" + (i >>> 1));
}

业务背景

  • 直播业务要实现一个在线观众列表,排序是下面两个规则.
    1、贡献值高的用户排在前面
    2、相同贡献值的情况, 进入直播间时间越早排在前面

基于上述业务背景,这边是采用redis的Zset这个数据结构来做在线观众排行榜.

/**
*   将{@code value}添加到{@code key}指定的有序集合中,如果该值已存在,则更新其{@code score}。

*   @param key 不能为空。
*   @param value 值。
*   @param score 分数。
Boolean add(K key, V value, double score);

正常使用zSet进行排序的时候就只能根据score进行单一维度排序,但是目前是两个维度进行排序,看上面redis插入数据的方法,知道排序字段scoredouble数据类型(64位).所以可参考雪花算法(41bit记录时间戳,其余bit位存储机房id、机器id、序列号)

image.png

  • 参考现有的业务规模,这边是定义38bit表示时间戳(低位),25bit表示贡献值(高位). 因为排序首先按贡献值排再按时间排,所以贡献值在高位,时间戳在低位,这样不管时间戳的值是多少,贡献值越大,64bit表示的数值就越大
  • 当贡献值相等时,时间戳越大表示的数值越大,我们想要的是先达到的数值越大(越靠前),我们可以用一个时间周期(比如一天)和用户达到的 贡献值的时间进行做差,这样这个值会随着时间的推移而变小,而且不会出现负数的情况,刚好能够达到目的
  • 这里使用8年时间周期,由于使用作差计算的方式(时间差),所以时间戳不会超过12位数字,只需要38bit,贡献值部分可以使用25bit,能够存储到三千万. 如果需要存储更大的贡献值,可以将相应时间戳位数减少.

具体的工具类实现


public class AbstractTimeScoreOperator {

/*** 并发情况下,需要精确到毫秒 **/
private static final DateTimeFormatter DEFAULT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]");
/*** 使用7年作为标准时间进行作差,保证存储的时间戳在38位 **/
private static final LocalDateTime STANDARD_DAY = LocalDateTime.parse("2030-01-01T00:00:00.000", DEFAULT_FORMAT);
/*** 2030年1月1日的时间戳,用来做减法 **/
private static final long PERIOD_END_TIME_STAMP = getTimestampOfDateTime(STANDARD_DAY);
/*** 64bit全为1的数,用来做移位操作 **/
private static final long FACTORS = 0xFFFFFFFFFFFFFFFFL;
/*** 左移位数,表示用多少位存储时间戳 **/
private static final int LEFT = 38;
/*** 用来存储
private static final int RIGHT = 64 - LEFT;



/**
 * 实现积分 + 时间戳差值转score
 * * @param point 用户的得分,由于只有25个bit位,所以point不能超过2^24
 (33,554,432 三千三百万),如果超过可以压缩时间戳bit位
 * 进场时间戳
 *
 * @return 返回计算后的score
 */
protected long toScore(int point, long timeStamp) {
   long score = 0L;
   score = (score | point) << LEFT;
   score = score | (PERIOD_END_TIME_STAMP - timeStamp);
   return score;
}

/**
 * 拿到高位的值(从score中获得积分)
 *
 * @param score 在redis中实际保存的score
 * @return 返回用户的积分
 */
protected int getPoint(long score) {
   return (int) (score >>> LEFT);
}

/**
 * 拿到低位的值(投票的时间戳),这里注意需要使用无符号右移 `>>>`
 * * @param score 在redis中实际保存的score
 *
 * @return 进场的时间戳
 */
protected long getTimeStamp(long score) {
   return PERIOD_END_TIME_STAMP - ((FACTORS >>> RIGHT) & score);
}
}

待处理:

double精度缺失问题

redis的官网提示,score的整数范围只能够精确表示 -(2^53) 和 (2^53) 之间的整数.

image.png

@Test
public void test() {
   
    long i = 1152921504606846000L; // 超过53位数值
    double d = i;
    log.info("转成double类型的值={}", d);
    // 1.15292150460684595E18
    log.info("重新转成long类型的值={}", (long) d);
    // 1152921504606845952
}

image.png

  • 第53位是0,无需处理
  • 第53位是1且53位之后全是0:
    • 若第52位是0,无需处理;
    • 若第52位是1,那么向上舍入
  • 第53位是1,且之后不全是0:那么向上舍入

所以上面结论就是=1152921504606846000-(16+32)=1152921504606845952

可以解决方式:

由于直播间进场的时间都是由第三方回调通知我们,然后基于线上的回调日志,发现回调的日志并不是串行的.还有在目前已有的业务场景,基于单位的进场时间,已经够满足业务需求.

image.png

上述就是改完之后的数据组成.贡献值(25位)代表三千多万,时间戳(差值-28位)表示17年

位运算在mysql数据库的运用

CREATE TABLE `tb_vip` (
  `id` bigint(20) NOT NULL COMMENT '主键id',
  `player_flag` tinyint(1) DEFAULT '0' COMMENT '是否玩家: 0 否 1 是 ',
  `group_leader_flag` tinyint(2) DEFAULT '0' COMMENT '是否团长 0:否(默认) 1:是',
  `certify_flag` tinyint(255) DEFAULT '0' COMMENT '实名认证状态【1-已认证,0-未认证】',
  `review_flag` tinyint(2) DEFAULT '0' COMMENT '审阅状态【1-已审阅,0-未审阅】'
   PRIMARY KEY (`id`) USING BTREE,
) ENGINE=InnoDB COMMENT='会员表';

通常我们设计的表业务中有多个字段表示各种状态属性,就比如现有的tb_vip表有上面几个字段表示用户的属性.这样会造成后期的维护困难,字段过多,索引增大的情况,这时使用位运算就可以巧妙的解决.

举个例子:
  • 玩家标记 0001 ->1
  • 团长标记 0010 ->2
  • 实名标记 0100 ->4
  • 审阅标记 1000 ->8

这样上述的表结构就可以优化成

CREATE TABLE `tb_vip` (
  `id` bigint(20) NOT NULL COMMENT '主键id',
  `vip_status` tinyint(2) DEFAULT '0' COMMENT '用户标记',
   PRIMARY KEY (`id`) USING BTREE,
) ENGINE=InnoDB COMMENT='会员表';

查询有玩家标记的数据

select * from tb_vip where vip_status & 1 = 1

查询有玩家标记的数据且有团长标记

select * from tb_vip where vip_status & 3 (0011) = 3

查询仅只有实名认证成功

select * from tb_vip where vip_status & 15(1111) =4(0100);

查询仅没有实名认证成功

select * from tb_vip where vip_status ^ 15(1111) = 11(1011);

Redis Bitmap

Bitmap(也称为位数组或者位向量等)是一种实现对位的操作的'数据结构',在数据结构加引号主要因为:

  • Bitmap 本身不是一种数据结构,底层实际上是字符串,可以借助字符串进行位操作。

  • Bitmap 单独提供了一套命令,所以与使用字符串的方法不太相同。可以把 Bitmaps 想象成一个以位为单位的数组,数组的每个单元只能存储 0 和 1,数组的下标在 Bitmap 中叫做偏移量 offset。

占用空间

如上我们知道 Bitmap 本身不是一种数据结构,底层实际上使用字符串来存储。由于 Redis 中字符串的最大长度是 512 MB字节,所以 BitMap 的偏移量 offset 值也是有上限的,其最大值是:8 * 1024 * 1024 * 512 = 2^32。由于 C 语言中字符串的末尾都要存储一位分隔符,所以实际上 BitMap 的偏移量 offset 值上限是:2^32-1。Bitmap 实际占用存储空间取决于 BitMap 偏移量 offset 的最大值,占用字节数可以用 (max_offset / 8) + 1 公式来计算

image.png

如果添加大于最大上限值,则会抛出下面异常

Caused by: redis.clients.jedis.exceptions.JedisDataException: ERR bit offset is not an integer or out of range
	at redis.clients.jedis.Protocol.processError(Protocol.java:135)
	at redis.clients.jedis.Protocol.process(Protocol.java:169)
	at redis.clients.jedis.Protocol.read(Protocol.java:223)
	at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:352)
	at redis.clients.jedis.Connection.getIntegerReply(Connection.java:294)
	at redis.clients.jedis.BinaryJedis.setbit(BinaryJedis.java:3810)
	at org.springframework.data.redis.connection.jedis.JedisInvoker.lambda$just$7(JedisInvoker.java:149)
	at org.springframework.data.redis.connection.jedis.JedisConnection.lambda$doInvoke$2(JedisConnection.java:176)
	at org.springframework.data.redis.connection.jedis.JedisConnection.doWithJedis(JedisConnection.java:799)
	... 78 more

比如现在想要统计每日的签到用户,假设现在有 10 个用户,用户id为 1、5、9 的 3 个用户在 20230712 时间点进行了登录,那么当前 Bitmap 初始化结果如下图所示

image.png

public static final String key = "20230712";

用户签到(setBit)

@Test
public void test() {
redisTemplate.opsForValue().setBit(key, 1, true);
redisTemplate.opsForValue().setBit(key, 5, true);
redisTemplate.opsForValue().setBit(key, 9, true);
}

校验用户是否签到(getBit)

@Test
public void test() {

Boolean sign = redisTemplate.opsForValue().getBit(key, 5)
// true
}

统计当天签到人数(bitCount)

@Test
public void test() {

Long num = redisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(key.getBytes()));
// 3
}

统计20230712和20230713都签到过的用户数量(bitOp)

@Test
public void test() {

private String new_key="20230712_20230713";


redisTemplate.execute((RedisCallback<Long>) connection ->
      connection.bitOp(RedisStringCommands.BitOperation.AND, new_key.getBytes(),"20230712".getBytes(),"20230713".getBytes()));
    
    
// 合并后的签到数量    
Long num =redisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(new_key.getBytes()));
        
}

统计当天前10个用户签到情况(bitField)

@Test
public void test() {


int total=10;
// 存储用户状态
Map<Integer, String> signMap = new LinkedHashMap<>(total);
//获取BitMap中的bit数组,并以十进制返回
List<Long> bitFieldList = redisTemplate.execute((RedisCallback<List<Long>>) cbk -> cbk.bitField(key.getBytes(),
      BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(total + 1)).valueAt(0)));
      // 为什么要+1 ,是因为是从第 0 bit开始算的
      // valueAt(0) 偏移量->是指当前二进制是否向左偏移
if (bitFieldList != null && bitFieldList.size() > 0) {
   Long valueDec = bitFieldList.get(0) != null ? bitFieldList.get(0) : 0L;
   //使用i--,从最低位开始处理
   for (int i = total; i > 0; i--) {
      if ((valueDec & 1) != 0) {
         signMap.put(i, "签到");
      } else {
         signMap.put(i, "未签到");
      }
      //每次处理完右移一位
      valueDec >>= 1;
   }
}

参考文章

二进制计算

使用redis Zset根据score和时间从多个维度进行排序(Zset榜单多维度排序)

IEEE-754 64位双精度浮点数存储详解

Redis Bitmap 学习和使用