java中的位运算
| 运算 | 运算符号 | 示例 |
|---|---|---|
| & | 与运算 | 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插入数据的方法,知道排序字段score是double数据类型(64位).所以可参考雪花算法(41bit记录时间戳,其余bit位存储机房id、机器id、序列号)
- 参考现有的业务规模,这边是定义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) 之间的整数.
@Test
public void test() {
long i = 1152921504606846000L; // 超过53位数值
double d = i;
log.info("转成double类型的值={}", d);
// 1.15292150460684595E18
log.info("重新转成long类型的值={}", (long) d);
// 1152921504606845952
}
- 第53位是0,无需处理
- 第53位是1且53位之后全是0:
- 若第52位是0,无需处理;
- 若第52位是1,那么向上舍入
- 第53位是1,且之后不全是0:那么向上舍入
所以上面结论就是=1152921504606846000-(16+32)=1152921504606845952
可以解决方式:
由于直播间进场的时间都是由第三方回调通知我们,然后基于线上的回调日志,发现回调的日志并不是串行的.还有在目前已有的业务场景,基于秒单位的进场时间,已经够满足业务需求.
上述就是改完之后的数据组成.贡献值(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 公式来计算
如果添加大于最大上限值,则会抛出下面异常
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 初始化结果如下图所示
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;
}
}